diff --git a/.cargo/config.toml b/.cargo/config.toml index 35049cbcb..6c26ee582 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,9 @@ [alias] xtask = "run --package xtask --" + +# WebAssembly SIMD for the web build: without it vello_cpu's SIMD layer (fearless_simd) runs its +# scalar fallback on wasm32 and the canvas renders about 2.4x slower (#2786). The web build then +# needs Chrome/Edge 91+, Firefox 89+ or Safari 16.4+. A RUSTFLAGS environment variable replaces +# these flags. +[target.wasm32-unknown-unknown] +rustflags = ["-C", "target-feature=+simd128"] diff --git a/ASSETS.md b/ASSETS.md index 34ff73361..17ab680ff 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -368,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau | `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` | | `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` | | `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` | +| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` | +| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` | +| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` | +| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` | +| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` | +| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` | +| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` | +| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` | +| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` | +| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` | ## Assets embedded by the PDF backend diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..9fd8db586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1442,7 +1442,7 @@ dependencies = [ "itertools", "log", "profiling", - "resvg", + "resvg 0.45.1", ] [[package]] @@ -2286,6 +2286,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heic-rs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" +dependencies = [ + "rayon", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -4429,11 +4438,29 @@ dependencies = [ "pico-args", "rgb", "svgtypes 0.15.3", - "tiny-skia", + "tiny-skia 0.11.4", "usvg 0.45.1", "zune-jpeg 0.4.21", ] +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia 0.12.0", + "usvg 0.48.1", + "zune-jpeg 0.5.15", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4718,7 +4745,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "log", "smithay-client-toolkit 0.19.2", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -5584,6 +5611,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" @@ -6226,7 +6268,9 @@ version = "0.21.0" dependencies = [ "image", "kurbo 0.13.1", + "resvg 0.48.1", "serde_json", + "svgtypes 0.16.1", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-geom", @@ -6246,10 +6290,12 @@ dependencies = [ "crc32fast", "flate2", "hayro-jpeg2000", + "heic-rs", "image", "kurbo 0.13.1", "log", "proptest", + "rayon", "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", @@ -6292,6 +6338,7 @@ dependencies = [ "memchr", "ruzstd", "serde_json", + "skrifa 0.47.0", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", @@ -6480,7 +6527,7 @@ dependencies = [ "image", "kurbo 0.13.1", "proptest", - "resvg", + "resvg 0.45.1", "serde", "serde_json", "simplecss", diff --git a/README.md b/README.md index d71e5b23a..08aa022a3 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ Installers and executables are code-signed. | Build | File | Notes | |---|---|---| -| Static site | `vectorcraft-web-.zip` | Runs in a modern browser; host it on any static server | +| Static site | `vectorcraft-web-.zip` | Runs in a browser with WebAssembly SIMD (Chrome/Edge 91, Firefox 89, Safari 16.4 or newer), see `packaging/web/README.md`; host it on any static server | ## The Crafting Apps diff --git a/ROADMAP.md b/ROADMAP.md index 0f9267e0e..504324eaf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,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-11-06 · **Change:** minor (Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates 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 @@ -34,6 +34,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| +| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | @@ -134,6 +135,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. @@ -223,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | | 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index f7faf7a92..376f43190 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-format = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-svg = { workspace = true } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 2d8df4592..8fc2661dc 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -1,6 +1,7 @@ //! `vectorcraft-cli`: VectorCraft from the command line. //! //! ```text +//! vectorcraft-cli --safe-mode SUBCOMMAND ... //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300] //! vectorcraft-cli commands @@ -72,6 +73,11 @@ const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation USAGE: + vectorcraft-cli --safe-mode SUBCOMMAND ... + Run any subcommand without plug-ins: none loads or runs, and plug-in commands report that + Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and + does not take --connect. + vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 (vectorcraft --control 7979), falling back to a headless in-process session. @@ -128,12 +134,17 @@ USAGE: Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. - vectorcraft-cli perf [--paths N] [--deep] + vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]] Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded, unless the report says the machine is busy. --deep also times work with no budget yet and prints memory use (the working set on Windows), with the peak so far on Windows and Linux. + --native-io instead measures the application's native save/open commands (one warmup, + five samples, no disk I/O), including preview, PDF, compression and API base64 costs. + It reports the default and explicit PDF opt-out, with and without preview/compression; + --artboards repeats the same artwork + on 1 to 4 overlapping artboards. This mode allows at most 50000 paths. "; /// The usage text plus the formats `document.open` reads and `document.export` writes. @@ -175,12 +186,18 @@ fn main() -> ExitCode { #[cfg(target_os = "macos")] mac_fonts::install(); prefs_dir::install_fonts(); - let args: Vec = std::env::args().skip(1).collect(); + let mut args: Vec = std::env::args().skip(1).collect(); + // Before any subcommand attaches the plug-in folder. + let safe_mode = args.first().is_some_and(|a| a == "--safe-mode"); + if safe_mode { + args.remove(0); + vectorcraft_engine::cmd::plugin::start_in_safe_mode(); + } let r = match args.first().map(String::as_str) { // `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the // usage. The other subcommands check for it themselves. Some("commands" | "info") if help_requested(&args[1..], &[]) => help(), - Some("mcp") => mcp(&args[1..]), + Some("mcp") => mcp(&args[1..], safe_mode), Some("run") => run(&args[1..]), Some("commands") => commands(), Some("convert") => convert(&args[1..]), @@ -213,7 +230,9 @@ fn with_stores(mut h: Headless) -> Headless { h } -fn mcp(args: &[String]) -> Result<(), String> { +/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own +/// plug-ins, so `--connect` is refused and the default does not connect. +fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> { if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) { return help(); } @@ -240,6 +259,9 @@ fn mcp(args: &[String]) -> Result<(), String> { if headless && connect.is_some() { return Err("use either --connect or --headless".into()); } + if safe_mode && connect.is_some() { + return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control `".into()); + } let read: Vec<&Path> = read_roots.iter().map(Path::new).collect(); let write: Vec<&Path> = write_roots.iter().map(Path::new).collect(); let roots = AutomationRoots::new(&read, &write)?; @@ -252,7 +274,7 @@ fn mcp(args: &[String]) -> Result<(), String> { file_access::confine_process(roots.clone()); eprintln!("vectorcraft-cli: files confined: {}", roots.describe()); Box::new(Headless::with_document().with_automation_roots(Some(roots))) - } else if headless { + } else if headless || safe_mode { Box::new(with_stores(Headless::with_document())) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3ff82512d..e533d00b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { v.get(v.len() / 2).copied().unwrap_or(0.0) } +/// Exercise the application's native save/open commands, including previews, PDF output, +/// compression and API base64 conversion. Session setup and validation are outside timing. +/// Five measured runs follow one warmup; no filesystem or GPU window is involved. +fn native_io(doc: &Document) -> Result<(), String> { + for (label, compatible, preview) in [ + ("default options", None, false), + ("PDF disabled", Some(false), false), + ("preview+gzip", None, true), + ("preview+gzip, PDF disabled", Some(false), true), + ] { + let mut session = vectorcraft_engine::Session::new(); + session.add_document(doc.clone(), None); + let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview}); + if let Some(value) = compatible { + params["pdfCompatible"] = json!(value); + } + let (mut saves, mut opens) = (Vec::new(), Vec::new()); + let (mut size, mut has_pdf, mut compressed) = (0, false, false); + for run in 0..6 { + let start = Instant::now(); + let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?; + let save_ms = start.elapsed().as_secs_f64() * 1000.0; + let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?; + let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?; + size = bytes.len(); + has_pdf = vectorcraft_format::pdf_content(&bytes).is_some(); + if compatible == Some(false) && has_pdf { + return Err("native save ignored the explicit PDF opt-out".into()); + } + compressed = vectorcraft_format::is_compressed(&bytes); + if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview { + return Err("native save did not apply the requested preview/compression options".into()); + } + let args = json!({"name":"perf.vectorcraft","dataBase64":data}); + let mut opened = vectorcraft_engine::Session::new(); + let start = Instant::now(); + opened.execute("document.open", &args).map_err(|e| e.to_string())?; + let open_ms = start.elapsed().as_secs_f64() * 1000.0; + if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() { + return Err("native round trip changed the artboard count".into()); + } + if run > 0 { + saves.push(save_ms); + opens.push(open_ms); + } + } + saves.sort_by(f64::total_cmp); + opens.sort_by(f64::total_cmp); + let save = saves.get(2).copied().ok_or("no save measurements")?; + let open = opens.get(2).copied().ok_or("no open measurements")?; + outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}"); + } + Ok(()) +} + /// 1-minute load average (macOS / Linux), if available. #[cfg(not(windows))] fn load_average() -> Option { @@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> { } pub fn run(args: &[String]) -> Result<(), String> { - if crate::help_requested(args, &["--paths"]) { + if crate::help_requested(args, &["--paths", "--artboards"]) { return crate::help(); } let mut n = 50_000usize; let mut want_deep = false; + let mut want_native = false; + let mut artboards = None::; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--deep" => want_deep = true, + "--native-io" => want_native = true, + "--artboards" => { + artboards = + Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?) + } "--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?, other => return Err(format!("unknown perf option `{other}`")), } } + if want_native && n > 50_000 { + return Err("--native-io allows at most 50000 paths".into()); + } + if want_native && want_deep { + return Err("--native-io and --deep are separate measurement modes".into()); + } + if artboards.is_some() && !want_native { + return Err("--artboards requires --native-io".into()); + } + let artboards = artboards.unwrap_or(1); let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); // The figure is the 1-minute load average, or on Windows the cores busy over one second. #[cfg(not(windows))] @@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> { outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); - let doc = synthetic(n)?; + let mut doc = synthetic(n)?; outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + if want_native { + let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?; + // Overlapping artboards intentionally repeat the same paths on every PDF page. + for index in 1..artboards { + let mut page = first.clone(); + page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?; + page.name = format!("Perf {}", index + 1); + doc.artboards.push(page); + } + outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O"); + return native_io(&doc); + } let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> { mod tests { use super::*; + #[test] + fn native_io_exercises_the_application_save_and_open_commands() { + native_io(&synthetic(3).unwrap()).unwrap(); + } + + #[test] + fn native_io_rejects_conflicting_modes_and_excessive_workloads() { + for (args, expected) in [ + (vec!["--native-io", "--deep"], "separate measurement modes"), + (vec!["--native-io", "--paths", "50001"], "at most 50000"), + (vec!["--native-io", "--artboards", "5"], "1 to 4"), + (vec!["--artboards", "1"], "requires --native-io"), + (vec!["--artboards", "2"], "requires --native-io"), + ] { + assert!(run(&args.into_iter().map(str::to_owned).collect::>()).unwrap_err().contains(expected)); + } + } + #[test] fn busy_share_leaves_out_idle_time() { // In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time, diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index 9caf4bc92..05a5cc5fc 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -283,6 +283,22 @@ fn mcp_headless_over_stdio() { assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); } +/// `--safe-mode mcp` serves a headless session without plug-ins: it refuses `--connect`, and by +/// default it does not connect to an app listening on the default address, which loads its own +/// plug-ins. +#[test] +fn safe_mode_mcp_does_not_connect_to_a_running_app() { + let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}"); + // Something listening on the default address, as a running app does; a running app or another + // program already holding it serves as well. + let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR); + let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}"); +} + /// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the /// server starts, and the process then reads and writes only inside its roots. #[test] diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index ff4bcef86..6e4dbd7f0 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -14,7 +14,7 @@ name = "vectorcraft-web" path = "src/main.rs" [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } # The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft. craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 170a7e5c7..4ab382d5c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"] accessibility = ["eframe/accesskit"] [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 3d9fef45c..2dfc4e6f4 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -8,6 +8,7 @@ use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; #[cfg(target_os = "macos")] use super::{mac_menu, mac_window, open_documents}; +use vectorcraft_ui_egui::dialogs::safe_mode; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -113,16 +114,26 @@ impl App { vectorcraft_ui_egui::background::wait_all(&mut self.app); return; } + // An app that stays in Safe Mode leaves later launches to start their own app with plug-ins. + #[cfg(not(target_os = "macos"))] + if safe_mode::staying_on(&self.app) { + single_instance::resign(); + } // Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file - // dialog is open: what asked for it runs again on its document when it answers. - if !self.app.file_dialog_open() { + // dialog is open: what asked for it runs again on its document when it answers. They also + // wait for the choice about a stopped plug-in, so they open after the plug-ins it loads, as + // do the command line's files held back for it. Files later launches handed over before the + // choice ended in Safe Mode open in a new app with plug-ins. + if !self.app.file_dialog_open() && !safe_mode::choosing(&self.app) { + let held = std::mem::take(&mut self.held_files); #[cfg(target_os = "macos")] let files = open_documents::take(); #[cfg(not(target_os = "macos"))] - let files = single_instance::take(); + let files = single_instance::take_for_frame(safe_mode::staying_on(&self.app), single_instance::launch_with_plug_ins); if !files.is_empty() { bring_window_forward(ctx); } + open_files(&mut self.app, held); open_files(&mut self.app, files); } let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor()); @@ -151,13 +162,15 @@ impl App { /// On macOS with accessibility, end the process once the app has saved what it keeps on quitting, /// before eframe tears the window down (#661). AccessKit then gives the window's content view back /// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the -/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit. +/// app as it quits on Macs with a Touch Bar. On a normal quit, only the launch marker is needed +/// after the window, so it is ended here as `main` ends it after the window on other platforms. /// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics /// failure then starts the app again on another adapter (#651). #[allow(unused_variables)] fn end_before_teardown(frames: u64) { #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] if frames >= gpu::STARTUP_FRAMES { + safe_mode::end_launch(); log::info!("quitting"); log::logger().flush(); std::process::exit(0); @@ -172,6 +185,16 @@ pub(crate) fn bring_window_forward(ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } +/// Open the command line's `files` now, or, while the choice about a stopped plug-in is up +/// ([`safe_mode::choosing`]), return them to open once it is made, with the plug-ins it loads. +pub(crate) fn open_or_hold(app: &mut VectorcraftApp, files: Vec) -> Vec { + if safe_mode::choosing(app) { + return files; + } + open_files(app, files); + Vec::new() +} + /// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec) { diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index d7d917ed4..0bf4732ed 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> { let field = |key: &str| { device_id.split(['\\', '&']).find_map(|part| { let (name, hex) = part.split_at_checked(key.len())?; - if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None } + if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None } }) }; Some((field("VEN_")?, field("DEV_")?)) diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 40e6a555c..f424280b3 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() { assert_eq!(record.crashed.len(), CRASHED_LIMIT); assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); } + +/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id. +#[test] +fn pci_ids_reject_a_sign_in_the_hex_digits() { + assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None); +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 8019276ec..b3a1fd06b 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,7 +1,10 @@ //! VectorCraft desktop app. //! //! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...] -//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! [--in-window-menus] [--new-instance] [--safe-mode] [--] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) +//! +//! Any other argument starting with '-' (not `-` alone) is an error; after `--`, every argument is +//! a file. //! //! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given //! (Windows and Linux; `single_instance`). @@ -65,6 +68,7 @@ mod xwayland; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::file_access::{self, AutomationRoots}; +use vectorcraft_ui_egui::dialogs::safe_mode; use vectorcraft_ui_egui::graphics::GraphicsLoss; use vectorcraft_ui_egui::picks::PickRequest; use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp}; @@ -77,7 +81,7 @@ mod titlebar; #[cfg(all(test, feature = "wgpu"))] use app::bring_window_forward; use app::native_input_hook; -use app::open_files; +use app::open_or_hold; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -92,7 +96,9 @@ use titlebar::custom_titlebar_from; use titlebar::opted_into_system_title_bar; #[cfg(windows)] use titlebar::set_taskbar_icon; -use titlebar::{custom_titlebar, native_options}; +#[cfg(all(test, feature = "wgpu"))] +use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame}; +use titlebar::{custom_titlebar, native_options, titlebar_from_env}; struct App { app: VectorcraftApp, @@ -111,6 +117,8 @@ struct App { pending_maximize: bool, /// Frames left to retry [`window::apply_pending_maximize`]. maximize_tries: u8, + /// The command line's files, held back until the choice about a stopped plug-in is made. + held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, } @@ -132,13 +140,89 @@ Options: (or VECTORCRAFT_IN_WINDOW_MENUS=1) --new-instance Open the files in a new window even when VectorCraft already runs (Windows and Linux) + --safe-mode Start without plug-ins: none loads or runs until + VectorCraft starts normally (Help › Safe Mode); the + files open in a new window, as with --new-instance --version Print the version and exit -h, --help Print this help and exit + -- Treat every argument after it as a file + +Any other argument starting with '-' is an error. Without a window (converting, scripting, the MCP server), use vectorcraft-cli: vectorcraft-cli --help "; +/// The command line's options other than the automation roots and the control port. +struct CommandLine { + files: Vec, + in_window_menus: bool, + new_instance: bool, + /// `--safe-mode`: no plug-in loads or runs in this launch. + safe_mode: bool, +} + +/// Reads the arguments after the program name. The automation roots and the control port go into +/// `roots_args` and `control`. `Err` is the exit code when the process ends here: `--version` and +/// `--help` print and succeed; a bad automation option or an unknown option prints an error and fails. +fn read_command_line( + mut args: impl Iterator, + control: &mut automation_args::ControlArg, + roots_args: &mut automation_args::AutomationArgs, +) -> Result { + let mut files = Vec::new(); + let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); + let mut new_instance = false; + let mut safe_mode = false; + let mut unknown = None; + while let Some(a) = args.next() { + // After `--`, every argument is a file, even one whose name starts with '-'. + if a == "--" { + files.extend(args.by_ref()); + break; + } + match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { + Ok(true) => continue, + Ok(false) => {} + Err(e) => { + eprintln!("vectorcraft: {e}"); + return Err(std::process::ExitCode::FAILURE); + } + } + match a.as_str() { + "--in-window-menus" => in_window_menus = true, + "--new-instance" => new_instance = true, + "--safe-mode" => safe_mode = true, + "--version" => { + println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); + return Err(std::process::ExitCode::SUCCESS); + } + // Before anything starts, like `--version`: from a terminal or an agent's shell the app + // used to open (or hang without a display) instead (#2110). + "--help" | "-h" => { + // Not `print!`, which panics when standard output is closed (`| head -0`): + // there is nothing left to tell then. + let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); + return Err(std::process::ExitCode::SUCCESS); + } + // The process serial number older macOS versions pass to an app the Finder starts. + #[cfg(target_os = "macos")] + _ if a.starts_with("-psn_") => {} + // An option this version doesn't know (a typo such as `--safe-mod`) is an error, not a + // file to open. Reported after the loop, so a later `--help` or `--version` still wins. + _ if a.starts_with('-') && a != "-" => { + unknown.get_or_insert(a); + } + _ => files.push(a), + } + } + if let Some(a) = unknown { + eprintln!("vectorcraft: unknown option {a}; see vectorcraft --help, or put -- before a file whose name starts with '-'"); + return Err(std::process::ExitCode::FAILURE); + } + Ok(CommandLine { files, in_window_menus, new_instance, safe_mode }) +} + fn main() -> std::process::ExitCode { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] @@ -154,37 +238,11 @@ fn main() -> std::process::ExitCode { let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); let mut roots_args = automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT")); - let mut files = Vec::new(); - let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); - let mut new_instance = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) { - Ok(true) => continue, - Ok(false) => {} - Err(e) => { - eprintln!("vectorcraft: {e}"); - return std::process::ExitCode::FAILURE; - } - } - match a.as_str() { - "--in-window-menus" => in_window_menus = true, - "--new-instance" => new_instance = true, - "--version" => { - println!("vectorcraft {}", env!("CARGO_PKG_VERSION")); - return std::process::ExitCode::SUCCESS; - } - // Before anything starts, like `--version`: from a terminal or an agent's shell the app - // used to open (or hang without a display) instead (#2110). - "--help" | "-h" => { - // Not `print!`, which panics when standard output is closed (`| head -0`): - // there is nothing left to tell then. - let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); - return std::process::ExitCode::SUCCESS; - } - _ => files.push(a), - } - } + let CommandLine { files, in_window_menus, new_instance, safe_mode: safe_mode_flag } = + match read_command_line(std::env::args().skip(1), &mut control, &mut roots_args) { + Ok(command_line) => command_line, + Err(code) => return code, + }; let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args; // After the arguments: `--control` and `--version` win over a bad environment value. let control_port = match control.port { @@ -202,10 +260,10 @@ fn main() -> std::process::ExitCode { // Files opened from the file manager while VectorCraft runs open in its window (#4892). Before // the log file is attached: a launch that hands its files over leaves the running app's log be. #[cfg(not(target_os = "macos"))] - let instance = match single_instance::start(control_port.is_some(), new_instance, &files) { + match single_instance::start(control_port.is_some(), new_instance, safe_mode_flag, &files) { single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS, - single_instance::Start::Running(primary) => primary, - }; + single_instance::Start::Running(primary) => single_instance::hold(primary), + } #[cfg(target_os = "macos")] let _ = new_instance; vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); @@ -396,10 +454,15 @@ fn main() -> std::process::ExitCode { let mut session = Session::new(); // The profiles loaded before come back before the preferences name any of them. prefs_dir::attach_profile_store(&mut session); + // Before any plug-in loads: this launch's marker in the Launch folder next to the + // preferences (none for runs without preferences), and Safe Mode. + let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch"))); + let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag); prefs_dir::attach_plugin_store(&mut session); let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); app.set_startup_update_check(true); load_prefs(&mut app, saved); + safe_mode::after_start(&mut app, stopped); // The saved PDF presets live in their own folder (also read by vectorcraft-cli), // which takes over from the copy the preferences file used to hold. prefs_dir::attach_pdf_preset_store(&mut app.session); @@ -466,7 +529,9 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; + app.desktop_frame = !custom_titlebar; if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); @@ -491,7 +556,7 @@ fn main() -> std::process::ExitCode { single_instance::set_ui(&cc.egui_ctx); } app.services.native_input_hook = native_input_hook(&app); - file_access::unconfined(|| open_files(&mut app, files)); + let held_files = file_access::unconfined(|| open_or_hold(&mut app, files)); // The first frame is due from now (`gpu::watch_first_frame`). #[cfg(feature = "wgpu")] created.ready(); @@ -507,6 +572,7 @@ fn main() -> std::process::ExitCode { winit_window, pending_maximize, maximize_tries: 8, + held_files, monitors_listed: None, })) }), @@ -515,7 +581,9 @@ fn main() -> std::process::ExitCode { // The window is gone: later launches start their own app, including the one a graphics // failure starts again below. #[cfg(not(target_os = "macos"))] - drop(instance); + single_instance::resign(); + // The process ends normally from here: this launch didn't stop while a plug-in was loading. + safe_mode::end_launch(); #[cfg(feature = "wgpu")] let outcome = gpu::finish(outcome, &startup); #[cfg(not(feature = "wgpu"))] diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs index 5ced4aef3..37a836f6f 100644 --- a/apps/vectorcraft/src/single_instance.rs +++ b/apps/vectorcraft/src/single_instance.rs @@ -12,8 +12,12 @@ //! as its own app, which replaces a stale file. Nothing received is ever run. //! //! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward -//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files -//! opened from Finder to the running app itself (`open_documents`), so this is off there. +//! nor listen, and neither does a `--safe-mode` launch, which opens its files in an app of its own +//! and leaves later launches to start theirs with plug-ins. `--new-instance` opens a launch's files +//! in an app of its own. An app that stays in Safe Mode after the choice about a stopped plug-in +//! stops listening ([`resign`]), and files later launches handed it before then open in a new app +//! with plug-ins ([`take_for_frame`]). macOS hands files opened from Finder to the running app itself +//! (`open_documents`), so this is off there. use std::fs::{self, OpenOptions}; use std::hash::{BuildHasher as _, RandomState}; @@ -43,6 +47,8 @@ const CLIENT_TIMEOUT: Duration = Duration::from_secs(5); /// Paths handed over by later launches that the UI hasn't opened yet. static PENDING: Mutex> = Mutex::new(Vec::new()); +/// This app while it is the one later launches hand their files to ([`hold`]). +static HELD: Mutex> = Mutex::new(None); /// The UI to wake when files arrive (unset until the window exists; the first frame opens them). static UI: OnceLock = OnceLock::new(); @@ -56,23 +62,46 @@ pub enum Start { /// Hand `files` to the app already running, or become the app later launches hand theirs to. /// `automation`: started with `--control`. -pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start { +pub fn start(automation: bool, new_instance: bool, safe_mode: bool, files: &[String]) -> Start { if automation || !prefs_enabled() { return Start::Running(None); } let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) }; + start_at(&path, new_instance, safe_mode, files) +} + +/// [`start`] with the endpoint file `path`. A launch with `--new-instance` or `--safe-mode` keeps +/// its files and runs as its own app: a running app would open them with its plug-ins loaded. A +/// `--safe-mode` launch never becomes the app later launches hand their files to, which would open +/// them without plug-ins. +fn start_at(path: &Path, new_instance: bool, safe_mode: bool, files: &[String]) -> Start { + if safe_mode { + return Start::Running(None); + } // With nothing to hand over, the same exchange only asks whether an app is running. let send: &[String] = if new_instance { &[] } else { files }; - match forward(&path, send) { + match forward(path, send) { Ok(()) if send.is_empty() => Start::Running(None), Ok(()) => Start::Forwarded, Err(e) => { log::debug!("no running VectorCraft to hand files to ({e})"); - Start::Running(listen(&path, queue)) + Start::Running(listen(path, queue)) } } } +/// Keep `primary` ([`start`]'s) until [`resign`]. +pub fn hold(primary: Option) { + *HELD.lock().unwrap_or_else(PoisonError::into_inner) = primary; +} + +/// Later launches start their own app from now on: the endpoint file goes. The app calls it when +/// its window is gone, and when it stays in Safe Mode after the choice about a stopped plug-in. +pub fn resign() { + let primary = HELD.lock().unwrap_or_else(PoisonError::into_inner).take(); + drop(primary); +} + /// Wake `ctx` when files arrive from later launches. pub fn set_ui(ctx: &egui::Context) { // Set once; a second call would hand over the same context. @@ -84,6 +113,43 @@ pub fn take() -> Vec { std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)) } +/// The paths later launches handed over since the last call, to open in this app. An app that +/// stays in Safe Mode (`safe_mode`) resigns and hands them to a new app with plug-ins (`launch`, +/// [`launch_with_plug_ins`] in the app) instead: they arrived while it was the primary, before the +/// choice about a stopped plug-in ended in Safe Mode, or just before it resigned. When `launch` +/// fails (logged), they open here. +pub fn take_for_frame(safe_mode: bool, launch: impl FnOnce(&[String]) -> io::Result<()>) -> Vec { + let files = take(); + if !safe_mode { + return files; + } + // First, so the new app becomes the primary instead of handing the files back. + resign(); + if files.is_empty() { + return files; + } + match launch(&files) { + Ok(()) => Vec::new(), + Err(e) => { + log::warn!("couldn't start VectorCraft with plug-ins for the files another launch opened: {e}"); + files + } + } +} + +/// Start this program again, with plug-ins, on `files`. +pub fn launch_with_plug_ins(files: &[String]) -> io::Result<()> { + app_with_plug_ins(&std::env::current_exe()?, files).spawn().map(drop) +} + +/// The command [`launch_with_plug_ins`] runs: `exe -- files…`, so a file whose name starts with '-' +/// stays a file. +fn app_with_plug_ins(exe: &Path, files: &[String]) -> std::process::Command { + let mut command = std::process::Command::new(exe); + command.arg("--").args(files); + command +} + fn queue(files: Vec) { PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files); if let Some(ctx) = UI.get() { @@ -263,6 +329,13 @@ mod tests { use super::*; + /// Held by the tests that use the process-wide [`HELD`] and [`PENDING`], one at a time. + static GLOBALS: Mutex<()> = Mutex::new(()); + + fn globals() -> std::sync::MutexGuard<'static, ()> { + GLOBALS.lock().unwrap_or_else(PoisonError::into_inner) + } + struct TestDir(tempfile::TempDir); impl std::ops::Deref for TestDir { @@ -313,6 +386,72 @@ mod tests { assert!(!path.exists(), "the endpoint file goes with the app"); } + #[test] + fn a_new_instance_or_safe_mode_launch_keeps_its_files() { + let (path, _dir, file) = folder("own"); + let (primary, rx) = primary(&path); + for (new_instance, safe_mode) in [(true, false), (false, true)] { + let start = start_at(&path, new_instance, safe_mode, std::slice::from_ref(&file)); + assert!(matches!(start, Start::Running(None)), "runs as its own app (new instance {new_instance}, Safe Mode {safe_mode})"); + } + assert!(matches!(start_at(&path, false, false, std::slice::from_ref(&file)), Start::Forwarded)); + assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file)); + assert!(rx.try_recv().is_err(), "only the plain launch handed its file over"); + drop(primary); + } + + #[test] + fn a_safe_mode_launch_leaves_later_launches_their_own_app() { + let _globals = globals(); + let (path, _dir, file) = folder("safe-mode-alone"); + // With no app running, a Safe Mode launch runs without listening. + assert!(matches!(start_at(&path, false, true, std::slice::from_ref(&file)), Start::Running(None))); + assert!(!path.exists(), "a Safe Mode launch records no endpoint"); + // A plain launch after it starts its own app and takes later launches' files. + let Start::Running(Some(primary)) = start_at(&path, false, false, std::slice::from_ref(&file)) else { + panic!("the plain launch runs and listens") + }; + assert!(path.exists()); + // An app that stays in Safe Mode after the choice gives that up. + hold(Some(primary)); + resign(); + assert!(!path.exists(), "the endpoint file goes with resign"); + } + + #[test] + fn files_handed_to_an_app_staying_in_safe_mode_go_to_a_new_app_with_plug_ins() { + let _globals = globals(); + // Nothing left over from a test that failed. + resign(); + drop(take()); + let (path, _dir, file) = folder("safe-mode-later"); + let Start::Running(Some(primary)) = start_at(&path, false, false, &[]) else { panic!("the first launch listens") }; + hold(Some(primary)); + // A later launch handed its file over while the choice about a stopped plug-in was up. + queue(vec![file.clone()]); + let mut launched = Vec::new(); + let here = take_for_frame(true, |files| { + launched = files.to_vec(); + Ok(()) + }); + assert!(here.is_empty(), "nothing opens in the Safe Mode app"); + assert_eq!(launched, std::slice::from_ref(&file), "a new app with plug-ins opens it"); + assert!(!path.exists(), "the app resigned before starting the new one"); + // When the new app can't start, the file opens here rather than being lost. + queue(vec![file.clone()]); + assert_eq!(take_for_frame(true, |_| Err(io::Error::other("no program"))), std::slice::from_ref(&file)); + // An app with plug-ins opens what it is handed. + queue(vec![file.clone()]); + assert_eq!(take_for_frame(false, |_| panic!("no new app")), [file]); + } + + #[test] + fn the_new_app_takes_every_name_as_a_file() { + let command = app_with_plug_ins(Path::new("vectorcraft"), &["-a.svg".to_owned(), "b.svg".to_owned()]); + let args: Vec<_> = command.get_args().collect(); + assert_eq!(args, ["--", "-a.svg", "b.svg"]); + } + #[test] fn a_wrong_token_garbage_and_oversized_messages_are_refused() { let (path, _dir, file) = folder("refused"); diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 116651fc8..555d829e0 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -36,6 +36,33 @@ fn shortcut_overrides_from_an_earlier_version_load() { assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported"); } +/// The command line's files wait for the choice about a stopped plug-in, so they open with the +/// plug-ins it loads; with no choice up they open at once. +#[test] +fn command_line_files_wait_for_the_choice_about_a_stopped_plug_in() { + let temp = tempfile::Builder::new().prefix("vectorcraft-held-files-").tempdir().unwrap(); + let file = temp.path().join("a.svg"); + std::fs::write(&file, r#""#).unwrap(); + let files = vec![file.to_string_lossy().into_owned()]; + // The files the app reads to open them. + let read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let reads = read.clone(); + let services = vectorcraft_ui_egui::Services { + read: Some(Box::new(move |p: &str| { + reads.borrow_mut().push(p.to_owned()); + std::fs::read(p).map_err(|e| e.to_string()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + app.ui.stopped_plugin = Some(serde_json::json!({"id": "test.held", "name": "Held"})); + assert_eq!(open_or_hold(&mut app, files.clone()), files, "held while the choice is up"); + assert!(read.borrow().is_empty(), "nothing opened before the choice"); + app.ui.stopped_plugin = None; + assert!(open_or_hold(&mut app, files.clone()).is_empty()); + assert_eq!(*read.borrow(), files, "opened with no choice up"); +} + #[test] fn opening_files_unminimizes_and_focuses_the_window() { let ctx = egui::Context::default(); @@ -164,6 +191,60 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() { assert_eq!(super::native_options(true).viewport.decorations, Some(false)); } +/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where +/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe +/// decides, and `VECTORCRAFT_TITLEBAR` takes precedence over both. +#[test] +fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() { + assert!(!super::opted_into_app_title_bar(None), "no preferences: the default"); + let off = ui_state_with_prefs(serde_json::json!({"appTitleBar": false})); + assert!(!super::opted_into_app_title_bar(Some(&off))); + let on = ui_state_with_prefs(serde_json::json!({"appTitleBar": true})); + assert!(super::opted_into_app_title_bar(Some(&on))); + + // (probed_app, overridden, forced_to_x11): without the preference, then with it. + let cases = [ + ((false, None, false), false, true), // KDE, Sway, X11: the desktop frame + ((true, None, false), true, true), // GNOME on Wayland: the app frame either way + ((true, None, true), false, true), // GNOME, the window opened through XWayland + ((false, Some(true), false), true, true), // VECTORCRAFT_TITLEBAR=app + ((false, Some(true), true), true, true), // VECTORCRAFT_TITLEBAR=app, the window opened through XWayland + ((true, Some(false), false), false, false), // VECTORCRAFT_TITLEBAR=desktop takes precedence over the preference + ((false, Some(false), true), false, false), + ]; + for ((probed_app, overridden, forced_to_x11), without, with) in cases { + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, false), without, "{probed_app} {overridden:?} {forced_to_x11}"); + assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, true), with, "{probed_app} {overridden:?} {forced_to_x11}"); + } +} + +/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame +/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame +/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference. +#[test] +fn the_start_frame_applies_the_app_title_bar_preference() { + use craft_window::{Button, Frame, Layout}; + let gnome = Layout { left: vec![Button::Close], right: vec![] }; + let cases = [ + // (probed, overridden, forced_to_x11, opted_into_app), expected + ((Frame::Desktop, None, false, false), Frame::Desktop), + ((Frame::Desktop, None, false, true), Frame::App(Layout::default())), + ((Frame::Desktop, None, true, true), Frame::App(Layout::default())), + ((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())), + ((Frame::App(gnome.clone()), None, true, false), Frame::Desktop), + ((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())), + ((Frame::Desktop, Some(false), false, true), Frame::Desktop), + ((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())), + ]; + for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases { + let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}"); + assert_eq!(super::start_frame(probed, overridden, forced_to_x11, opted_into_app), expected, "{label}"); + } + // With both preferences on, System Title Bar takes precedence: the desktop draws the frame. + let both = super::start_frame(Frame::Desktop, None, false, true); + assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference"); +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 63c9f7c13..6e7451ed9 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -46,6 +46,53 @@ pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::Ui saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) } +/// Whether the user asked for VectorCraft's own title bar in Preferences › User Interface › +/// VectorCraft Title Bar on Every Desktop (Linux and the BSDs). Read like +/// [`opted_into_system_title_bar`]; a missing or unreadable value is `false` (the default). +pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + saved.map(|ui| ui.engine_prefs.get("appTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) +} + +/// Whether the app draws the frame on this start, before the System Title Bar preference is +/// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and +/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when it is +/// set; VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window +/// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe +/// decides. +pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> bool { + match overridden { + Some(app) => app, + None => opted_into_app || (probed_app && !forced_to_x11), + } +} + +/// The frame this start uses, before the System Title Bar preference is applied +/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed +/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default +/// layout, since `custom_titlebar_from` reads only which frame it is. +pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame { + let probed_app = matches!(probed, craft_window::Frame::App(_)); + match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) { + (true, frame @ craft_window::Frame::App(_)) => frame, + (true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()), + (false, _) => craft_window::Frame::Desktop, + } +} + +/// The `craft-window` probe's answer for this process (its override variable is +/// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection. +fn probed_choice() -> &'static craft_window::Choice { + static CHOICE: std::sync::OnceLock = std::sync::OnceLock::new(); + CHOICE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"))) +} + +/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › +/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always +/// `false` on macOS and Windows. +pub(crate) fn titlebar_from_env() -> bool { + cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some() +} + /// Whether this start draws its own title bar, given the frame the shared `craft-window` crate /// decided and the user's System Title Bar preference. /// @@ -82,24 +129,27 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through /// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs /// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. +/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins, +/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]). pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); + let choice = probed_choice(); let opted_into_system = opted_into_system_title_bar(saved); + // macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from` + // ignores the frame there). + let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved); #[cfg(target_os = "linux")] - { + // When the window will actually open on X11 (XWayland) — the user asked for it *and* the X11 + // libraries are present, the same two conditions the launch path checks below — that run is + // effectively X11, so keep the native frame. If the libraries are missing, startup stays on + // Wayland, so the probe's own decision (app-drawn on GNOME) must stand. + let forced_to_x11 = { let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); - // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window - // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are - // present, the same two conditions the launch path checks below — that run is effectively - // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so - // the probe's own decision (app-drawn on GNOME) must stand. - let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); - if choice.overridden().is_none() && forced_to_x11 { - return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); - } - } - custom_titlebar_from(choice.frame(), opted_into_system) + xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found() + }; + #[cfg(not(target_os = "linux"))] + let forced_to_x11 = false; + let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app); + custom_titlebar_from(&frame, opted_into_system) } pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { diff --git a/apps/vectorcraft/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs index a4ef28dbb..b370bf214 100644 --- a/apps/vectorcraft/src/windows_alt_codes.rs +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -13,6 +13,13 @@ //! same undo step as the typing round it. The keypad digits themselves are taken out of the frame, //! so they don't run a shortcut bound to Alt and a digit. //! +//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right, +//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the +//! separate navigation keys by the extended-key flag, which only the separate keys set +//! (), +//! so with Alt held they count as digits and are taken out of the frame too, rather than moving +//! the caret before the character arrives. +//! //! egui reports a keypad digit as the same key as the digit above the letters, so the other //! platforms, whose systems have no such convention, can't tell them apart either. @@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102; const WM_SYSKEYDOWN: u32 = 0x0104; const WM_SYSKEYUP: u32 = 0x0105; const WM_SYSCHAR: u32 = 0x0106; -/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys -/// are Insert, End, … and type no code. +/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. const VK_NUMPAD0: usize = 0x60; const VK_NUMPAD9: usize = 0x69; +/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`, +/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`. +const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21]; +/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it +/// comes as the top-row digit key, `Num5`). +const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] = + [Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp]; +const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; +/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the +/// keypad (the keypad's own keys clear it). +const EXTENDED_KEY: isize = 1 << 24; /// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt. const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5]; @@ -51,23 +68,42 @@ struct AltCodes { high: Option, /// Characters the codes typed, for the next frame. typed: String, - /// Keypad digit presses and releases with Alt held since the last frame, which egui reports - /// as Alt and a digit: taken out of the frame. - keypad_events: usize, + /// The digits of the keypad presses and releases with Alt held since the last frame, which + /// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken + /// out of the frame. + keypad_events: Vec, +} + +/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and +/// `lparam` the message's flags. +fn keypad_digit(wparam: usize, lparam: isize) -> Option { + if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) { + return Some(wparam - VK_NUMPAD0); + } + if lparam & EXTENDED_KEY != 0 { + return None; + } + NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam) } impl AltCodes { - /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit). - fn message(&mut self, message: u32, wparam: usize) { - let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam); - match message { + /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit) + /// and `lparam` (its flags). + fn message(&mut self, message: u32, wparam: usize, lparam: isize) { + let keypad = match message { + WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam), + _ => None, + }; + if let Some(d) = keypad { // Windows sends a key pressed with Alt held as a system key. - WM_SYSKEYDOWN if keypad => { + if message == WM_SYSKEYDOWN { self.digits += 1; - self.keypad_events += 1; self.armed = false; } - WM_SYSKEYUP if keypad => self.keypad_events += 1, + self.keypad_events.push(d); + return; + } + match message { WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {} // Any other key ends the sequence and keeps its own meaning. WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(), @@ -99,14 +135,23 @@ impl AltCodes { /// out the keypad digits typed with Alt held. fn drain(&mut self, events: &mut Vec) { let mut keypad = std::mem::take(&mut self.keypad_events); - if keypad > 0 { + if !keypad.is_empty() { events.retain(|e| { - let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt); - if digit && keypad > 0 { - keypad -= 1; - return false; + let Event::Key { key, modifiers, .. } = e else { + return true; + }; + if !modifiers.alt { + return true; + } + // The first keypad key still to take out that egui reports as `key`. + let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key); + match keypad.iter().position(stands_for) { + Some(i) => { + keypad.remove(i); + false + } + None => true, } - true }); } if !self.typed.is_empty() { @@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) { if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) { CODES.with(|c| { if let Ok(mut c) = c.try_borrow_mut() { - c.message(msg.message, msg.wParam.0); + c.message(msg.message, msg.wParam.0, msg.lParam.0); } }); } @@ -152,14 +197,14 @@ mod tests { /// Alt held, keypad digits `code`, Alt released, and the character Windows then posts. fn alt_code(c: &mut AltCodes, code: &str, posted: u16) { - c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_SYSKEYDOWN, ALT, 0); for d in code.bytes() { let vk = VK_NUMPAD0 + usize::from(d - b'0'); - c.message(WM_SYSKEYDOWN, vk); - c.message(WM_SYSKEYUP, vk); + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); } - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(posted)); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); } fn key(key: Key, alt: bool, pressed: bool) -> Event { @@ -186,11 +231,11 @@ mod tests { fn a_character_without_a_code_before_it_is_left_to_winit() { let mut c = AltCodes::default(); // A plain key press's character (winit types it), and Alt pressed and released alone. - c.message(WM_KEYDOWN, 0x41); - c.message(WM_CHAR, usize::from(b'a')); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(b'b')); + c.message(WM_KEYDOWN, 0x41, 0); + c.message(WM_CHAR, usize::from(b'a'), 0); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(b'b'), 0); let mut events = vec![]; c.drain(&mut events); assert!(events.is_empty(), "{events:?}"); @@ -199,12 +244,12 @@ mod tests { #[test] fn another_key_while_alt_is_held_cancels_the_code() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); // Alt+F: a shortcut, not a code. - c.message(WM_SYSKEYDOWN, 0x46); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0x41); + c.message(WM_SYSKEYDOWN, 0x46, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); let mut events = vec![]; c.drain(&mut events); assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}"); @@ -213,13 +258,81 @@ mod tests { #[test] fn a_code_above_the_basic_plane_arrives_as_two_halves() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0xD83D); - c.message(WM_CHAR, 0xDE00); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0xD83D, 0); + c.message(WM_CHAR, 0xDE00, 0); let mut events = vec![]; c.drain(&mut events); assert_eq!(events, vec![Event::Text("😀".into())]); } + + /// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the + /// extended-key bit), Alt released, and the character Windows then posts. + fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) { + c.message(WM_SYSKEYDOWN, ALT, 0); + for d in code.bytes() { + let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')]; + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); + } + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); + } + + #[test] + fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() { + let mut c = AltCodes::default(); + alt_code_numlock_off(&mut c, "0176", 0xB0); + // What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a + // release each, then a plain Home typed after Alt came up. + let mut events = vec![]; + for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] { + events.push(key(k, true, true)); + events.push(key(k, true, false)); + } + events.push(key(Key::Home, false, true)); + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]); + } + + #[test] + fn with_numlock_off_keypad_five_counts_too() { + let mut c = AltCodes::default(); + // Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear; + // 0234 uses Insert, Down, Page Down and Left. + alt_code_numlock_off(&mut c, "0151", 0x2014); + alt_code_numlock_off(&mut c, "0234", 0xEA); + let mut events = + vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)]; + c.drain(&mut events); + assert_eq!(events, vec![Event::Text("—ê".into())]); + } + + #[test] + fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() { + let mut c = AltCodes::default(); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0); + // Alt+Home on the separate Home key (extended): a shortcut, not a digit. + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); + let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)]; + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]); + } + + #[test] + fn numlock_off_keys_without_alt_are_left_alone() { + let mut c = AltCodes::default(); + c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0); + c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0); + let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)]; + c.drain(&mut events); + assert_eq!(events.len(), 2); + } } diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index 059b9c476..ece66b98a 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -54,7 +54,7 @@ fn prints_usage(args: &[&str], env: &[(&str, &str)]) { assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}"); - for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] { + for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--safe-mode", "--version", "--help"] { assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}"); } // Headless work is the CLI's: the usage says where to go. @@ -74,3 +74,27 @@ fn help_prints_usage_and_exits_before_startup() { fn help_wins_over_a_bad_control_port_variable() { prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]); } + +/// An option the app doesn't know (a typo of `--safe-mode`) is an error, not a file to open with +/// plug-ins; a later `--help` still wins. +#[test] +fn an_unknown_option_is_an_error_and_not_a_file() { + for args in [&["--safe-mod"][..], &["drawing.svg", "--safe-mod"], &["-x"]] { + let out = run(args, &[]); + assert!(!out.status.success(), "{args:?}"); + assert!(out.stdout.is_empty(), "{args:?}"); + let stderr = String::from_utf8(out.stderr).unwrap(); + let unknown = args.iter().find(|a| a.starts_with('-')).unwrap(); + assert_eq!( + stderr, + format!("vectorcraft: unknown option {unknown}; see vectorcraft --help, or put -- before a file whose name starts with '-'\n"), + "{args:?}" + ); + } + prints_usage(&["--safe-mod", "--help"], &[]); + // After `--`, a name starting with '-' is a file: the run goes on to the control port, which + // `run` makes fail. + let out = run(&["--", "-x.svg"], &[]); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains("VECTORCRAFT_CONTROL_PORT") && !stderr.contains("unknown option"), "{stderr}"); +} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 00910baab..80f4e0993 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18; /// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; +/// Child references inspected, page rectangles produced, and outline segments retained, in all +/// (shared objects are visited once per link, so each visit is charged). Real documents stay +/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that. +const MAX_DOCUMENT_WORK: usize = 1 << 24; + +/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy). +const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20; + /// What a whole read may still use. Shared objects can be linked from many layers, and embedded /// documents are read with what their embedding document left, so linking or embedding the /// same content many times can't multiply the work. @@ -44,6 +52,10 @@ pub(crate) struct Budget { pub(crate) cells: usize, /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). pub(crate) pressure: usize, + /// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all). + pub(crate) work: usize, + /// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all). + pub(crate) name_chars: usize, } impl Budget { @@ -53,7 +65,15 @@ impl Budget { chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS, pressure: MAX_DOCUMENT_PRESSURE_POINTS, + work: MAX_DOCUMENT_WORK, + name_chars: MAX_DOCUMENT_NAME_CHARS, }; + + /// Take `n` units of work, or fail the read. + pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> { + self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?; + Ok(()) + } } /// Read a document: archive, `doc.dat` and the model. @@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { return Ok(None); } let pages = pages(s, spread); + self.budget.spend(pages.len() + 1)?; let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { x0: 0.0, @@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let s = self.s; let mut out = Vec::new(); let mut mask = None; - for c in s.objs(parent, b"Chld") { + let kids = s.objs(parent, b"Chld"); + // Invisible masks and skipped children cost no node, so the visit is charged here. + self.budget.spend(kids.len())?; + for c in kids { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. if s.bool(c, b"Visi") == Some(false) { @@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect(); + self.budget.name_chars -= name.chars().count(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn(format!("a {} that could not be read", describe(class))); return Ok(Kind::Unsupported); }; + self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?; let (fills, strokes) = paint::node_paint(self, id, world); Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } } @@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn shared_work_is_bounded() { + let mut b = Budget::FULL; + assert!(b.spend(MAX_DOCUMENT_WORK).is_ok()); + assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data"))); + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 12102581e..c7b2acbbd 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), - _ => Vec::new(), + // Convert only the positions that pair with a colour, as shared attributes repeat this per run. + let posn: &[Value] = match s.field(grad, b"Posn") { + Some(Value::Array(a)) => a, + _ => &[], }; let mut stops = Vec::new(); - for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) { + for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) { let color = color(r, *c)?; let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index d458c08a7..bb8c1c88b 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl if let Some(name) = s.entry(bitmap, b"Bckg") { let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + // The whole source is decoded for every layer that reads it, so charge it all, not only the crop. + let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else { + return Err(Error::Limit("embedded image pixels")); + }; + r.budget.pixels = left; match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) { Ok(pixels) => Some((width as usize, pixels)), Err(Error::Limit(e)) => return Err(Error::Limit(e)), @@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + // A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference. + let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE)); let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), _ => Vec::new(), }; (tw, th, states) diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 1a3b6e544..d2bb55ca0 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096; /// Cells a table may have (rows × columns): each becomes a text object with its edge lines. const MAX_TABLE_CELLS: usize = 1 << 16; +/// Work charged for each copy of a run's formatting a table cell keeps. +const TABLE_RUN_COST: usize = 16; + /// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; @@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option
{ let mut parts = run.text.split(CELL_BREAK).peekable(); while let Some(part) = parts.next() { if !part.is_empty() { + // Each cell keeps its own copy of the run's formatting, so each copy is charged. match target.and_then(|i| cells.get_mut(i)) { - Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - None => extra = true, + Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), + _ => extra = true, } } if parts.peek().is_some() { @@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec (Vec = Vec::new(); let mut open: Option = None; - for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + for run in paragraph_runs { let Some(attrs) = s.obj(run, b"Item") else { continue }; let a = paragraph(r, attrs); first_align.get_or_insert(a.align); @@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec &'static str { + match self { + Self::Custom => "custom", + Self::Cnc => "cnc", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Cnc => "CNC / CAM", + } + } + + /// A preset by id (`custom`, `cnc`), any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } + + /// The options the preset sets (Custom: the defaults); the region and crop are the defaults. + pub fn options(self) -> DxfOptions { + match self { + Self::Custom => DxfOptions::default(), + Self::Cnc => DxfOptions { + version: DxfVersion::R2000, + unit: Unit::Millimeters, + scale: 1.0, + scale_lineweights: false, + colors: ColorDepth::Aci256, + raster: RasterFormat::Png, + preserve: Preserve::Editability, + alter_paths: false, + outline_text: true, + ..DxfOptions::default() + }, + } + } + + /// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom. + pub fn of(o: &DxfOptions) -> Self { + let c = Self::Cnc.options(); + let same = o.version == c.version + && o.unit == c.unit + && o.scale == c.scale + && o.scale_lineweights == c.scale_lineweights + && o.colors == c.colors + && o.raster == c.raster + && o.preserve == c.preserve + && o.alter_paths == c.alter_paths + && o.outline_text == c.outline_text; + if same { Self::Cnc } else { Self::Custom } + } +} + /// An image file a drawing links to, to be written next to it under `name`. #[derive(Clone, Debug)] pub struct DxfImage { diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 72fa4f7b3..45ed99299 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> { let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); - // A right-to-left paragraph's marker is drawn with its tab first and its number's - // period at the number's left; the TEXT entity holds the characters in that order. + // A right-to-left paragraph's marker is drawn with its number's period at the + // number's left; the TEXT entity holds the characters in that order. let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') { - Some(number) if m.tab == m.glyphs.start => format!(".{number}"), + Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"), _ => m.text.clone(), }); let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index d99858511..90e4cbc2c 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() { assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err()); } +#[test] +fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() { + let mut d = text_doc("Cut", 20.0); + let layer = d.layers[0].id; + let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0); + d.insert(Some(layer), usize::MAX, frame).unwrap(); + let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() }; + let out = export(&d, &o).unwrap(); + let p = pairs(&out.bytes); + assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4")); + let k = kinds(&out); + assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}"); + assert!(k.len() > 2, "the glyphs' outlines: {k:?}"); + // The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline. + assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}"); + assert!(out.images.is_empty()); + assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); + assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); + assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); @@ -1052,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() { /// A numbered list paragraph's number and period are a TEXT entity of their own where they are /// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the -/// number's left. +/// number's left, with the marker aligned left or right. #[test] fn a_number_is_text_of_its_own_before_its_paragraph() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - let numbered = |text: &str, count: u32| { + use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList}; + let numbered_aligned = |text: &str, count: u32, align: ListAlign| { let mut d = text_doc(text, 20.0); let id = d.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - t.para.list = Some(ParaList::numbered(NumberStyle::Arabic)); + t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); t.para.left_indent = 30.0; t.para.first_line_indent = -30.0; t.list_count = count; @@ -1068,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() { let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::>() }; + let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left); assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]); let rtl = numbered("שלום", 11); assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}"); + // Aligned right, the number keeps its order and moves right, against the text. + let right = numbered_aligned("Item", 11, ListAlign::Right); + assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0))); + assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}"); + let rtl = numbered_aligned("שלום", 11, ListAlign::Right); + assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); + // A mixed paragraph: the period follows the paragraph's direction, not the script of the + // words it holds, with either alignment. + let marker = |text: &str, align: ListAlign| { + let out = numbered_aligned(text, 11, align); + out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default() + }; + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(marker("Item שלום", align), "12.", "{align:?}"); + assert_eq!(marker("שלום Item", align), ".12", "{align:?}"); + } } /// Area type that Shrink Text to Fit scales down is written at the height it draws at. diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index 95abdf2f3..3d532ac02 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -1,19 +1,26 @@ //! "VectorCraft Generic CMYK (SWOP-like)": our own parametric press model. //! //! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is -//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers -//! are rounded, typical values for coated web offset, not a measured dataset. +//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*. Two of its +//! figures are published values for SWOP printing: the tone value increase of each ink at 40% and +//! 50% from CGATS TR 001 (), and the 300% +//! total ink limit (). +//! The paper, solid and overprint colors and the black-generation curve are VectorCraft's own values. //! -//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain -//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY -//! Neugebauer primaries with Demichel weights; black is mixed in the same way against -//! "primary + K" colours whose optical densities add but saturate towards a maximum density -//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth. +//! * **Forward (CMYK → XYZ):** each ink's nominal dot area `a` is widened by a tone curve +//! `a + 4·g·a·(1−a)·(1 + s·(2a−1))` that passes through the TR 001 tone value increase at 50% +//! (`g`) and 40% (which sets `s`). The effective areas mix the 16 Neugebauer primaries (paper and +//! every solid and overprint of C, M, Y and K, media-relative) with Demichel weights, linearly in +//! XYZ, so a single ink's colorimetric tone value increase (Murray–Davies) is the TR 001 value. //! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a -//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component -//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour +//! fixed K. Black generation is GCR: solve CMY with K = 0, take the gray component +//! `min(C, M, Y)`, add [`BlackGeneration::shortfall`] for each L* point by which that solve stays +//! lighter than the target, map the sum through the black curve to K, re-solve CMY. Under-color //! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling -//! CMY as a last resort. +//! CMY as a last resort. The black point, which source black separates to and which displays as +//! black with black-point compensation, is [`BlackGeneration::black_point_k`] black with neutral +//! CMY filling the TAC. With compensation, a build darker than it in every XYZ channel also +//! displays as black (see [`GenericCmyk::display_xyz`]). //! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not //! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression //! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps @@ -24,50 +31,82 @@ use std::sync::OnceLock; use super::Intent; use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb}; -/// Black generation strength. +/// Black generation. #[derive(Clone, Copy, Debug, PartialEq)] pub struct BlackGeneration { - /// Grey component below which no black is generated. - pub start: f32, - /// Maximum black. - pub max_k: f32, - /// Curve exponent (>1 = lighter black in the midtones). - pub gamma: f32, + /// The black curve as `(x, K)` knots with rising `x`, read by linear interpolation. `x` is the + /// gray component of the solve without black plus [`Self::shortfall`] per L* point by which + /// that solve stays lighter than the target. + pub curve: [(f32, f32); 13], + /// Added to the gray component for each L* point the black-free solve stays too light. + pub shortfall: f32, + /// Black of the black point, whose CMY is neutral and fills the TAC. + pub black_point_k: f32, } /// Parameters of the parametric press model. #[derive(Clone, Debug)] pub struct GenericCmykParams { pub name: &'static str, + /// Paper color (absolute Lab, D50). pub paper: Lab, - /// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY. - pub solids: [Lab; 7], - pub black: Lab, - /// Dot gain at 50% per ink (C, M, Y, K). - pub dot_gain: [f32; 4], + /// Media-relative Lab of the solids and overprints, Demichel order (index `c | m<<1 | y<<2 | + /// k<<3`, paper left out): C, M, CM, Y, CY, MY, CMY, then K, CK, MK, CMK, YK, CYK, MYK, CMYK. + pub primaries: [Lab; 15], + /// Tone value increase at 50% per ink (C, M, Y, K). + pub tvi50: [f32; 4], + /// Tone value increase at 40% per ink (C, M, Y, K). + pub tvi40: [f32; 4], /// Total area coverage limit (sum of inks, 1.0 = 100%). pub tac: f32, pub bg: BlackGeneration, } -/// Rounded, typical SWOP-like values (not measured data). +/// The built-in SWOP-like press: TR 001 tone value increase, a 300% ink limit, and VectorCraft's +/// own paper, primaries and black curve. pub fn swop_like() -> GenericCmykParams { GenericCmykParams { name: super::GENERIC_CMYK, paper: Lab::new(93.0, -0.5, 2.5), - solids: [ - Lab::new(55.0, -37.0, -50.0), // C - Lab::new(48.0, 74.0, -3.0), // M - Lab::new(24.0, 22.0, -46.0), // CM (blue) - Lab::new(89.0, -5.0, 93.0), // Y - Lab::new(50.0, -65.0, 27.0), // CY (green) - Lab::new(47.0, 68.0, 48.0), // MY (red) - Lab::new(24.0, 1.0, 0.0), // CMY + primaries: [ + Lab::new(65.7, -42.6, -47.2), // C + Lab::new(55.5, 76.2, -9.4), // M + Lab::new(31.1, 20.3, -49.7), // CM (blue) + Lab::new(96.2, -7.0, 90.1), // Y + Lab::new(61.0, -68.4, 30.1), // CY (green) + Lab::new(55.0, 69.8, 45.0), // MY (red) + Lab::new(28.3, 0.7, -0.5), // CMY + Lab::new(21.5, 0.8, -0.9), // K + Lab::new(15.9, -8.4, -10.0), // CK + Lab::new(14.7, 15.4, -1.0), // MK + Lab::new(11.2, 8.9, -6.6), // CMK + Lab::new(20.2, -2.4, 12.0), // YK + Lab::new(15.7, -14.6, 4.6), // CYK + Lab::new(14.5, 11.2, 5.7), // MYK + Lab::new(11.0, -0.7, 0.1), // CMYK ], - black: Lab::new(19.0, 0.5, 0.5), - dot_gain: [0.20, 0.20, 0.20, 0.22], + tvi50: [0.200, 0.176, 0.168, 0.216], + tvi40: [0.200, 0.173, 0.167, 0.219], tac: 3.0, - bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 }, + bg: BlackGeneration { + curve: [ + (0.0, 0.0), + (0.25, 0.0), + (0.35, 0.006), + (0.45, 0.059), + (0.55, 0.139), + (0.65, 0.23), + (0.75, 0.322), + (0.85, 0.41), + (0.95, 0.511), + (1.05, 0.6), + (1.2, 0.733), + (1.35, 0.858), + (1.5, 1.0), + ], + shortfall: 0.03, + black_point_k: 0.9, + }, } } @@ -83,11 +122,12 @@ const GBD_H: usize = 72; // 5° bins pub struct GenericCmyk { pub params: GenericCmykParams, - /// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K. - prim: [[f32; 3]; 8], - prim_k: [[f32; 3]; 8], + /// Media-relative XYZ of the 16 primaries (index = c | m<<1 | y<<2 | k<<3; 0 is paper). + prim: [[f32; 3]; 16], + /// Shape `s` of each ink's tone curve, from its 40% and 50% tone value increase. + skew: [f32; 4], paper_xyz: [f32; 3], - /// Media-relative XYZ of the darkest printable black (for black-point compensation). + /// Media-relative XYZ of the black point (for black-point compensation). black_rel: [f32; 3], gbd: OnceLock, } @@ -95,37 +135,17 @@ pub struct GenericCmyk { impl GenericCmyk { pub fn new(params: GenericCmykParams) -> Self { let paper_xyz = lab_to_xyz(params.paper); - let mut prim = [[0.0f32; 3]; 8]; - prim[0] = paper_xyz; - for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) { - *p = lab_to_xyz(*lab); + let mut prim = [D50; 16]; + for (p, lab) in prim[1..].iter_mut().zip(params.primaries.iter()) { + *p = lab_to_xyz(*lab).map(|v| v.max(1e-6)); } - let k_xyz = lab_to_xyz(params.black); - // Black over other inks: optical densities add, but saturate towards a maximum density - // (ink trapping / surface reflection), normalised so that K on paper is exact. - const D_MAX: f32 = 2.0; - let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp()); - let mut prim_k = [[0.0f32; 3]; 8]; - for i in 0..8 { - for ch in 0..3 { - let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10(); - let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10(); - let d = sat(di + dk) * dk / sat(dk).max(1e-6); - prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di)); - } - } - let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; - // Darkest black within TAC: search a few rich blacks. - let mut best = [1.0f32; 3]; - for k in [0.8f32, 0.9, 1.0] { - let rest = (g.params.tac - k).max(0.0) / 3.0; - let cmy = rest.min(1.0); - let x = g.to_xyz_rel([cmy, cmy, cmy, k]); - if x[1] < best[1] { - best = x; - } - } - g.black_rel = best; + // The curve's increase at 40% is 0.96·g·(1 − 0.2·s), so `s` follows from the 40% value. + let skew = [0, 1, 2, 3].map(|i| { + let g = params.tvi50[i]; + if g > 1e-6 { (1.0 - params.tvi40[i] / (0.96 * g)) / 0.2 } else { 0.0 } + }); + let mut g = Self { params, prim, skew, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; + g.black_rel = g.to_xyz_rel(g.black_point_build()); g } @@ -133,35 +153,81 @@ impl GenericCmyk { self.params.name } - fn gain(a: f32, g: f32) -> f32 { - let a = a.clamp(0.0, 1.0); - (a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0) + /// The black point: [`BlackGeneration::black_point_k`] black, with C, M and Y + /// chosen so that they fill the TAC and the color is neutral (a* = b* = 0). + pub fn black_point_build(&self) -> [f32; 4] { + let k = self.params.bg.black_point_k.clamp(0.0, 1.0); + let rest = (self.params.tac - k).clamp(0.0, 3.0); + let build = |c: f32, m: f32| [c, m, (rest - c - m).clamp(0.0, 1.0), k]; + let ab = |c: f32, m: f32| { + let l = self.to_lab_rel(build(c, m)); + [l.a, l.b] + }; + let lo = (rest - 2.0).max(0.0); + let hi = rest.min(1.0); + let (mut c, mut m) = ((rest / 3.0).clamp(lo, hi), (rest / 3.0).clamp(lo, hi)); + for _ in 0..30 { + let r = ab(c, m); + if r[0].abs() + r[1].abs() < 1e-4 { + break; + } + let h = 1e-3; + let rc = ab(c - h, m); + let rm = ab(c, m - h); + let j = [[(r[0] - rc[0]) / h, (r[0] - rm[0]) / h], [(r[1] - rc[1]) / h, (r[1] - rm[1]) / h]]; + let det = j[0][0] * j[1][1] - j[0][1] * j[1][0]; + if det.abs() < 1e-9 { + break; + } + let dc = (-r[0] * j[1][1] + r[1] * j[0][1]) / det; + let dm = (-r[1] * j[0][0] + r[0] * j[1][0]) / det; + c = (c + dc.clamp(-0.1, 0.1)).clamp(lo, hi); + m = (m + dm.clamp(-0.1, 0.1)).clamp(lo, hi); + // Yellow takes the rest, so keep it within 0..1 as well. + let y = rest - c - m; + if y > 1.0 { + c += (y - 1.0) / 2.0; + m += (y - 1.0) / 2.0; + } else if y < 0.0 { + c += y / 2.0; + m += y / 2.0; + } + c = c.clamp(lo, hi); + m = m.clamp(lo, hi); + } + build(c.clamp(lo, hi), m.clamp(lo, hi)) + } + + /// Effective dot area of ink `i` at nominal area `a`: the TR 001-style tone curve. + fn gain(&self, i: usize, a: f32) -> f32 { + let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 }; + let g = self.params.tvi50[i]; + (a + 4.0 * g * a * (1.0 - a) * (1.0 + self.skew[i] * (2.0 * a - 1.0))).clamp(0.0, 1.0) } /// CMYK (0..1) → absolute XYZ (D50). pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] { - let dg = self.params.dot_gain; - let c = Self::gain(cmyk[0], dg[0]); - let m = Self::gain(cmyk[1], dg[1]); - let y = Self::gain(cmyk[2], dg[2]); - let k = Self::gain(cmyk[3], dg[3]); - let mut out = [0.0f32; 3]; - for i in 0..8 { - let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y }); - if w == 0.0 { - continue; - } - for (ch, o) in out.iter_mut().enumerate() { - *o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]); - } - } - out + let x = self.to_xyz_rel(cmyk); + [x[0] * self.paper_xyz[0] / D50[0], x[1] * self.paper_xyz[1] / D50[1], x[2] * self.paper_xyz[2] / D50[2]] } /// Media-relative XYZ (paper = D50 white). pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] { - let x = self.to_xyz(cmyk); - [x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]] + let e = [0, 1, 2, 3].map(|i| self.gain(i, cmyk[i])); + let mut out = [0.0f32; 3]; + for (i, p) in self.prim.iter().enumerate() { + let mut w = 1.0; + for (bit, a) in e.iter().enumerate() { + w *= if i & (1 << bit) != 0 { *a } else { 1.0 - a }; + } + if w == 0.0 { + continue; + } + for (o, v) in out.iter_mut().zip(p) { + *o += w * v; + } + } + out } pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab { @@ -174,7 +240,9 @@ impl GenericCmyk { /// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour). /// With `bpc` (media-relative only) the press black displays as black, undoing the /// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the - /// darkest rich black shows as black again. + /// black point shows as black again. Each XYZ channel is clamped at 0 on its own, so a build + /// darker than the black point in every channel shows as black, and one darker in only some + /// channels shows as a colored near-black. pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { if absolute { return self.to_xyz(cmyk); @@ -192,7 +260,7 @@ impl GenericCmyk { xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc)) } - /// Lab of the darkest printable black (media-relative). + /// Lab of the black point (media-relative), see [`Self::black_point_build`]. pub fn black_point(&self) -> Lab { xyz_to_lab(self.black_rel) } @@ -283,12 +351,21 @@ impl GenericCmyk { rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0)) } - fn black_for_grey(&self, grey: f32) -> f32 { - let bg = self.params.bg; - if grey <= bg.start { - return 0.0; + /// Black for `x` on the black curve (see [`BlackGeneration::curve`]). + fn black_for(&self, x: f32) -> f32 { + let curve = &self.params.bg.curve; + let mut prev = curve[0]; + if x <= prev.0 { + return prev.1.clamp(0.0, 1.0); } - (((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0) + for &(kx, kk) in &curve[1..] { + if x <= kx { + let t = if kx > prev.0 { (x - prev.0) / (kx - prev.0) } else { 1.0 }; + return (prev.1 + (kk - prev.1) * t).clamp(0.0, 1.0); + } + prev = (kx, kk); + } + prev.1.clamp(0.0, 1.0) } /// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit. @@ -297,13 +374,10 @@ impl GenericCmyk { return [0.0; 4]; } let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0)); - let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]); - let mut k = self.black_for_grey(grey); - // If CMY alone can't get dark enough, black must carry the rest. + let gray = cmy0[0].min(cmy0[1]).min(cmy0[2]); + // When CMY alone can't get dark enough, black carries the rest. let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l; - if l0 > target.l + 1.0 { - k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0)); - } + let mut k = self.black_for(gray + self.params.bg.shortfall * (l0 - target.l).max(0.0)); let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 }; // UCR: trade CMY for K until within the TAC limit. for _ in 0..6 { diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index ecb4c1c59..57709abe6 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -245,8 +245,8 @@ fn loads_system_cmyk_icc() { assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]); } -/// With black-point compensation, sRGB black separates to the press's darkest black and that black -/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey). +/// With black-point compensation, sRGB black separates to the press's black point and that black +/// displays as black again (hex 000000 -> C75 M68 Y67 K90 -> 000000, not a dark gray). #[test] fn generic_cmyk_black_round_trips_with_bpc() { let c = cms(); @@ -259,25 +259,104 @@ fn generic_cmyk_black_round_trips_with_bpc() { assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); } -/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few -/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024 -/// in all) stays within 0.001 on every ink. +/// Each ink's 40% and 50% tints gain like CGATS TR 001 (SWOP): the colorimetric tone value increase +/// (Murray–Davies on X for cyan, Y for magenta and black, Z for yellow) is C 20.0, M 17.6, Y 16.8 and +/// K 21.6 points at 50%, and C 20.0, M 17.3, Y 16.7 and K 21.9 points at 40%. +#[test] +fn generic_cmyk_tints_gain_like_tr001() { + let g = generic(); + let tvi = |ink: usize, a: f32| { + let ch = [0, 1, 2, 1][ink]; + let mut cmyk = [0.0f32; 4]; + cmyk[ink] = a; + let tint = g.to_xyz_rel(cmyk)[ch]; + cmyk[ink] = 1.0; + let solid = g.to_xyz_rel(cmyk)[ch]; + let paper = g.to_xyz_rel([0.0; 4])[ch]; + (paper - tint) / (paper - solid) - a + }; + for (ink, (at50, at40)) in [(0.200, 0.200), (0.176, 0.173), (0.168, 0.167), (0.216, 0.219)].into_iter().enumerate() { + assert!((tvi(ink, 0.5) - at50).abs() < 0.002, "ink {ink} at 50%: {}", tvi(ink, 0.5)); + assert!((tvi(ink, 0.4) - at40).abs() < 0.002, "ink {ink} at 40%: {}", tvi(ink, 0.4)); + } +} + +/// More of any ink never makes a Generic CMYK color lighter, in any XYZ channel: on a 10% grid of +/// builds within the 300% ink limit, adding 10% of one ink keeps X, Y and Z or lowers them. +#[test] +fn generic_cmyk_more_ink_is_never_lighter() { + let g = generic(); + let steps = (0..=10).map(|i| i as f32 / 10.0); + for c in steps.clone() { + for m in steps.clone() { + for y in steps.clone() { + for k in steps.clone() { + let base = [c, m, y, k]; + if base.iter().sum::() > 3.0 - 0.1 + 1e-4 { + continue; + } + let x0 = g.to_xyz_rel(base); + for ink in 0..4 { + if base[ink] >= 1.0 { + continue; + } + let mut more = base; + more[ink] += 0.1; + let x1 = g.to_xyz_rel(more); + assert!((0..3).all(|ch| x1[ch] <= x0[ch] + 1e-5), "{base:?} plus ink {ink}: {x0:?} to {x1:?}"); + } + } + } + } + } +} + +/// sRGB black separates to 90% black under neutral CMY that fills the 300% ink limit, light grays +/// carry no black, and black rises steadily from the light end of the gray ramp to the dark end. +#[test] +fn generic_cmyk_black_generation_follows_the_black_curve() { + let c = cms(); + let black = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric); + assert!((black[3] - 0.9).abs() < 0.005, "{black:?}"); + assert!((black.iter().sum::() - 3.0).abs() < 0.01, "{black:?}"); + let lab = c.cmyk_to_lab(black); + assert!(lab.a.abs() < 0.05 && lab.b.abs() < 0.05, "neutral: {lab:?}"); + let light = c.srgb_to_cmyk([0.7; 3], Intent::RelativeColorimetric); + assert_eq!(light[3], 0.0, "{light:?}"); + let mut prev = 0.0; + for v in (0..=50).rev().map(|i| i as f32 / 50.0) { + let k = c.srgb_to_cmyk([v; 3], Intent::RelativeColorimetric)[3]; + assert!(k >= prev - 1e-4, "gray {v}: K {k} after {prev}"); + prev = k; + } +} + +/// A color whose solve holds an ink at a bound separates to the same inks when its values change by +/// a few float steps: hex f01482 (cyan held at 0) and hex ed1e79 (magenta held at 100%), each with +/// its red stepped one `f32` at a time over 4,001 values (about 0.00024 in all), stay within 0.001 +/// on every ink. #[test] fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() { let c = cms(); - let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]; - let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); - for step in -2000i32..=2000 { - let mut rgb = pink; - rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); - let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); - for i in 0..4 { - lo[i] = lo[i].min(ink[i]); - hi[i] = hi[i].max(ink[i]); + let spread = |pink: [f32; 3]| { + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -2000i32..=2000 { + let mut rgb = pink; + rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } } - } + (lo, hi) + }; + let (lo, hi) = spread([240.0f32 / 255.0, 20.0 / 255.0, 130.0 / 255.0]); assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); assert_eq!(lo[0], 0.0, "{lo:?}"); + let (lo, hi) = spread([237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]); + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!(hi[1], 1.0, "{hi:?}"); } /// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 81b9aaaf5..3ac145fd2 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -296,6 +296,28 @@ impl Appearance { scale(e, s); } } + /// The id of Effect › SVG Filters' effect, whose `origin` parameter is a point of the document. + pub const SVG_FILTER: &str = "svg.filter"; + + /// Does any effect (the object's or a fill's or stroke's) hold an SVG filter? + pub fn has_svg_filter(&self) -> bool { + self.effects.iter().chain(self.items.iter().flat_map(|i| i.effects().iter())).any(|e| e.id == Self::SVG_FILTER) + } + + /// Move the `origin` of every SVG filter effect (where its user space starts in the document) + /// by `a`, as the object it is on moves. + pub fn transform_svg_filters(&mut self, a: kurbo::Affine) { + let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in self.effects.iter_mut().chain(items).filter(|e| e.id == Self::SVG_FILTER) { + let at = + |i: usize| e.params.get("origin").and_then(|o| o.get(i)).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let p = a * kurbo::Point::new(at(0), at(1)); + if p.x.is_finite() && p.y.is_finite() && e.params.is_object() { + e.params["origin"] = serde_json::json!([p.x, p.y]); + } + } + } + /// Gradient paints of the fills and strokes. fn gradients_mut(&mut self) -> impl Iterator { self.items.iter_mut().filter_map(|i| match i { @@ -354,6 +376,11 @@ impl Appearance { pub fn has_placed_gradient(&self) -> bool { self.has_gradient(true) } + /// Does a fill or stroke carry a gradient that is drawn at a place in document space: a placed + /// one, or a freeform gradient with points of its own whose vector is fitted to the bounds? + pub fn has_positioned_gradient(&self) -> bool { + self.gradients().any(|g| g.is_placed() || g.has_freeform_points()) + } /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index 6d5d50d4d..d7bfcafca 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -106,6 +106,60 @@ impl super::Document { d.layers.insert(0, Arc::new(layer)); Some(d) } + + /// Where an artboard the size of artboard `i` goes on `side` of it, [`ARTBOARD_GAP`] away, + /// when there is room for it there: every side within [`crate::MAX_COORD`] of the origin and + /// no other artboard overlapping it (touching edges don't overlap). `None` when there is no + /// such artboard or no room. + pub fn room_beside_artboard(&self, i: usize, side: ArtboardSide) -> Option { + let src = self.artboards.get(i)?.rect; + let r = src + side.offset(src); + let in_canvas = [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD); + let clear = self.artboards.iter().all(|a| !(r.x0 < a.rect.x1 && a.rect.x0 < r.x1 && r.y0 < a.rect.y1 && a.rect.y0 < r.y1)); + (in_canvas && clear).then_some(r) + } +} + +/// The space between an artboard and one added or duplicated beside it (VectorCraft's own value, +/// the space New Artboard and Duplicate Artboards leave right of the last artboard). +pub const ARTBOARD_GAP: f64 = 20.0; + +/// A side of an artboard, where an artboard added or duplicated beside it goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArtboardSide { + Left, + Right, + Top, + Bottom, +} + +impl ArtboardSide { + pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left]; + + /// The side a command's `side` param names: "left", "right", "top" or "bottom". + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|s| s.name() == name) + } + + pub fn name(self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + Self::Top => "top", + Self::Bottom => "bottom", + } + } + + /// How far a copy of `r` moves to sit on this side of it, [`ARTBOARD_GAP`] away (y down). + pub fn offset(self, r: Rect) -> vectorcraft_geom::Vec2 { + let (w, h) = (r.width() + ARTBOARD_GAP, r.height() + ARTBOARD_GAP); + match self { + Self::Left => vectorcraft_geom::Vec2::new(-w, 0.0), + Self::Right => vectorcraft_geom::Vec2::new(w, 0.0), + Self::Top => vectorcraft_geom::Vec2::new(0.0, -h), + Self::Bottom => vectorcraft_geom::Vec2::new(0.0, h), + } + } } /// A document's locked artboards ([`Document::locked_artboards`]). diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 3c4451d54..f17783b1e 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -11,12 +11,16 @@ use serde::{Deserialize, Serialize}; use crate::{Document, NodeId}; /// One asset: what it is called (its files' names) and the objects it exports, cropped to their -/// visual bounds. +/// visual bounds, or for a 3D asset its object's solids as a mesh file. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportAsset { pub id: u64, pub name: String, pub nodes: Vec, + /// A 3D asset's mesh format (`usda`, `usdz`, `gltf` or `obj`): its object exports as a 3D + /// file. `None` for art exported as pictures. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_3d: Option, } /// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`… @@ -30,7 +34,16 @@ impl Document { /// The name of the next unnamed asset: one more than the highest `Asset N` so far. pub fn next_asset_name(&self) -> String { - let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::().ok()).max().unwrap_or(0); + let n = self + .assets + .iter() + .filter_map(|a| { + let digits = a.name.strip_prefix(ASSET)?; + // `parse` alone would accept a leading `+`. + digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::().ok()).flatten() + }) + .max() + .unwrap_or(0); format!("{ASSET}{}", n.saturating_add(1)) } @@ -57,3 +70,23 @@ impl Document { v.into_iter().map(|(_, id)| id).collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn name_after(names: &[&str]) -> String { + let mut doc = Document::new(100.0, 100.0); + for (i, n) in names.iter().enumerate() { + doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None }); + } + doc.next_asset_name() + } + + #[test] + fn next_asset_name_ignores_non_digit_suffixes() { + assert_eq!(name_after(&["Asset +5"]), "Asset 1"); + assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1"); + assert_eq!(name_after(&["Asset 5"]), "Asset 6"); + } +} diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index bc44b7bf4..d25be2c41 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -19,7 +19,7 @@ use std::f64::consts::PI; use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; -use vectorcraft_geom::kurbo::ParamCurveArclen; +use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; @@ -904,9 +904,12 @@ pub struct Spine { cum: Vec, anchors: Vec, closed: bool, + path: PathData, } impl Spine { + /// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative + /// arc lengths, and the arc length at each anchor. pub fn new(path: &PathData) -> Option { let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); @@ -943,8 +946,9 @@ impl Spine { if pts.len() < 2 { return None; } - Some(Self { pts, cum, anchors, closed: sp.closed }) + Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() }) } + /// The spine's flattened length in arc length. pub fn length(&self) -> f64 { *self.cum.last().unwrap_or(&0.0) } @@ -1002,20 +1006,32 @@ impl Spine { pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) } - /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). + /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and + /// tangent come from the original curve at that arc length, not from the flattened polyline: a + /// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with + /// `Align to Path` jump between chords instead of following the curve smoothly. pub fn at_length(&self, s: f64) -> (Point, f64) { - let last = self.pts.len().saturating_sub(2); let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; - let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { - Ok(i) => i.min(last), - Err(i) => i.saturating_sub(1).min(last), - }; - let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else { - return (self.pts.first().copied().unwrap_or_default(), 0.0); - }; - let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0); - let d = *p1 - *p0; - (p0.lerp(*p1, u), d.y.atan2(d.x)) + let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) }; + let n = sp.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.segment(i); + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start = end; + } + // Only a spine without segments gets here: the loop returns at the last one. + (self.pts.first().copied().unwrap_or_default(), 0.0) } } @@ -1446,6 +1462,45 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn orientations_follow_a_curved_spine_smoothly() { + // A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles + // (+50, -50) / (-50, +50) — the classic up-bow arc. + let spine = PathData::single(SubPath::new( + vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + // Many steps along the bow: each step's tangent comes from the curve at its arc length, + // not from a flattened chord — so the orientations must sweep the true angle, smoothly. + let len = sp.length(); + let mut prev: Option = None; + for i in 1..=200 { + let (pt, ang) = sp.at_length(len * i as f64 / 201.0); + if let Some(p) = prev { + let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs()); + assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y); + } + prev = Some(ang); + } + } + + /// Where the spine starts at an anchor whose handle sits on it, the first step still faces along + /// the curve (towards the next handle), not along a direction of zero length. + #[test] + fn a_handle_on_its_anchor_still_gives_the_curve_direction() { + let spine = PathData::single(SubPath::new( + vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + let (p, angle) = sp.at_length(0.0); + assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}"); + assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}"); + let (_, end) = sp.at_length(sp.length()); + assert!(end.is_finite(), "{end}"); + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index cf85507c7..114235696 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -313,8 +313,8 @@ mod tests { d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ - crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] }, - crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] }, + crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, + crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)], format_3d: None }, ]; d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 78f90dac6..ebd27fd87 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -34,6 +34,7 @@ impl Document { graphic_styles, char_styles, para_styles, + composite_fonts, text_threads, symbols, graph_designs, @@ -68,6 +69,7 @@ impl Document { print_setup, assets, svg_scripts, + svg_filters, puppet, new_art, dimensions, @@ -89,6 +91,7 @@ impl Document { && *graphic_styles == other.graphic_styles && *char_styles == other.char_styles && *para_styles == other.para_styles + && *composite_fonts == other.composite_fonts && *text_threads == other.text_threads && symbols.len() == other.symbols.len() && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) @@ -135,6 +138,7 @@ impl Document { && *print_setup == other.print_setup && *assets == other.assets && *svg_scripts == other.svg_scripts + && *svg_filters == other.svg_filters && match (puppet, &other.puppet) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (a, b) => a.is_none() && b.is_none(), @@ -211,6 +215,7 @@ impl Document { graphic_styles: default_graphic_styles().into(), char_styles: Default::default(), para_styles: Default::default(), + composite_fonts: Default::default(), text_threads: vec![], symbols: vec![], graph_designs: vec![], @@ -245,6 +250,7 @@ impl Document { print_setup: None, assets: vec![], svg_scripts: vec![], + svg_filters: vec![], puppet: None, new_art: Default::default(), dimensions: false, diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index efd7f80b2..c40dcf9f2 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -123,6 +123,39 @@ impl Document { std::borrow::Cow::Owned(d) } + /// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions, + /// graph designs, pattern swatches (the one being edited included) and their opacity masks? + pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool { + let roots = self + .layers + .iter() + .chain(self.symbols.iter().map(|s| &s.art)) + .chain(self.graph_designs.iter().map(|g| &g.art)) + .chain(self.patterns.iter().flat_map(|p| p.art.iter())) + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + let mut any = false; + for root in roots { + crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n)); + } + any + } + + /// Change with `f` every node for which `wants` holds, anywhere in the document's art (as + /// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art + /// they share. + pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) { + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + edit_branches_where(root, &wants, &mut f); + } + } + /// Round every path's anchors and handles to `decimals` decimal places: path objects (with /// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine /// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being @@ -164,7 +197,7 @@ impl Document { } } NodeKind::Envelope { kind, .. } => { - if let EnvelopeKind::TopObject { path } = kind.as_mut() { + if let EnvelopeKind::TopObject { path, .. } = kind.as_mut() { round_path(path); } } @@ -194,6 +227,26 @@ impl Document { } } +/// Change with `f` the nodes of `n`'s tree (opacity-mask art included) for which `wants` holds, +/// copying out of the shared art only the branches that hold one. +fn edit_branches_where(n: &mut Arc, wants: &impl Fn(&Node) -> bool, f: &mut impl FnMut(&mut Node)) { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |c| any |= wants(c)); + if !any { + return; + } + let node = Arc::make_mut(n); + if wants(node) { + f(node); + } + if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) { + edit_branches_where(&mut m.art, wants, f); + } + for c in node.children_mut().into_iter().flatten() { + edit_branches_where(c, wants, f); + } +} + /// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every /// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as /// [`Document::round_geometry`] rounds the nodes it holds. diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b3b4394a2..ea3575bf6 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -936,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option } } +/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and +/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance +/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. +struct InstanceArt { + art: Node, + map: Option, + xf: vectorcraft_geom::Affine, +} + +impl InstanceArt { + /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that + /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, + /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. + fn of(doc: &Document, inst: &Node) -> Option { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); + let map = doc.symbol_slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + return None; + } + Some(Self { art, map, xf: *xf }) + } + + /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the + /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell + /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides + /// object by object. + fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + } + + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by + /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when + /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as + /// [`Document::place_symbol_art`] refuses it. + fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } + + /// Object `n` of the art placed on the canvas without clip groups + /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the + /// same limit ([`Document::place_symbol_art_unclipped`]). + fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + match (&self.map, whole) { + (_, Some(a)) => Some(moved(n, a)), + (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (None, None) => Some(moved(n, self.xf)), + } + } +} + +/// `n` transformed by `xf`, its strokes at their own weight. +fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { + let mut out = n.clone(); + out.transform(xf, false); + out +} + +/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's +/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the +/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the +/// outline of the clipping path of a clip group or a clipping layer picks that path before the art +/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed +/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper +/// group's original shapes, which the canvas does not draw, and guide paths with +/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice +/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not +/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. +pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { + struct Find<'a> { + doc: &'a Document, + art: &'a InstanceArt, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + } + impl Find<'_> { + fn hit(&self, n: &Node, whole: Option) -> bool { + self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + } + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + fn find(&self, n: &Node, path: &mut Vec, whole: Option) -> bool { + if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { + return false; + } + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole); + } + let whole = self.art.whole(n, self.doc, whole); + if n.clips() + && let Some(first) = children.first() + { + let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + if self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && clip_outline(&clip, self.p, self.opt).is_some() + { + path.push(0); + return true; + } + if !clip_contains(&clip, self.p) { + return false; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if self.find(c, path, whole) { + return true; + } + path.pop(); + } + false + } + } + let art = InstanceArt::of(doc, inst)?; + let symbols = doc.symbol_boxes(); + let mut path = vec![]; + Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) +} + +/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), +/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a +/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip +/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An +/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut +/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`] +/// picks it. +/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies. +pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option { + let art = InstanceArt::of(doc, inst)?; + let part = crate::symbol_overrides::part(&art.art, path)?; + let mut out = BezPath::new(); + let mut limit = None; + let mut whole = None; + let mut n = &art.art; + for &i in path { + let children = n.children()?; + whole = art.whole(n, doc, whole); + if i != 0 + && n.clips() + && let Some(first) = children.first() + && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + { + match meet(Some(clip), limit) { + Some(r) => limit = Some(r), + None => return Some(out), + } + } + n = children.get(usize::try_from(i).ok()?)?; + } + outline_within(&art.place(part, doc, whole)?, limit, &mut out); + Some(out) +} + +/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one. +fn outline_within(n: &Node, limit: Option, out: &mut BezPath) { + use vectorcraft_geom::kurbo::Shape; + if let Some(pd) = n.path_data() { + match limit { + Some(r) => push_cut(out, &pd.to_bezpath(), r), + None => out.extend(pd.to_bezpath()), + } + } else if n.children().is_none() + && let Some(b) = n.geometric_bounds() + && let Some(b) = meet(Some(b), limit) + { + out.extend(b.to_path(0.1)); + } + let Some(children) = n.children() else { return }; + if n.clips() + && let Some((first, rest)) = children.split_first() + { + let inner = match first.geometric_bounds() { + Some(c) => match meet(Some(c), limit) { + Some(r) => Some(r), + None => return, + }, + None => limit, + }; + rest.iter().for_each(|c| outline_within(c, inner, out)); + return; + } + children.iter().for_each(|c| outline_within(c, limit, out)); +} + +/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap. +fn meet(a: Option, b: Option) -> Option { + let a = a?; + let Some(b) = b else { return Some(a) }; + let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1)); + (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r) +} + +/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`. +fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) { + use vectorcraft_geom::kurbo::PathEl; + let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::); + vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| { + let to = match el { + PathEl::MoveTo(q) => { + (at, start, last) = (q, q, None); + return; + } + PathEl::LineTo(q) => q, + PathEl::ClosePath => start, + _ => return, + }; + if let Some((a, b)) = cut_segment(at, to, r) { + if last != Some(a) { + out.move_to(a); + } + out.line_to(b); + last = Some(b); + } + at = to; + }); +} + +/// The part of segment `a` to `b` inside `r`, `None` when none of it is. +fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let d = b - a; + let (mut t0, mut t1) = (0.0_f64, 1.0_f64); + for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] { + if p.abs() < 1e-12 { + if q < 0.0 { + return None; + } + } else if p < 0.0 { + t0 = t0.max(q / p); + } else { + t1 = t1.min(q / p); + } + } + (t0 <= t1).then(|| (a + d * t0, a + d * t1)) +} + /// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is /// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an /// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`. diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 9a5c40478..495dc227b 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1373,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() { assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); assert_eq!(checked, 201); } + +/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does: +/// Direct Selection picks an object only where the mask shows it, and outlines it there. +#[test] +fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art"); + assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it"); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}"); + // Its outline inside the group around the layer is cut too. + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap()); + assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}"); +} + +/// In a dynamic symbol's art that placing through its 9-slice guides could make more than +/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the +/// canvas draws nothing of it. At the art's natural size the same art is picked. +#[test] +fn symbol_art_past_the_object_limit_picks_nothing() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + // Drawn in six cells, each with its own copy of the mask's 3,240,000 squares. + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default())); + let scaled = d.alloc_id(); + let natural = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap(); + d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit"); + let opt = HitOptions::default(); + let n = d.node(scaled).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square"); + assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path"); + assert!(symbol_part_outline(&d, n, &[0]).is_none()); + assert!(symbol_part_outline(&d, n, &[1]).is_none()); + let n = d.node(natural).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0])); + assert!(symbol_part_outline(&d, n, &[0]).is_some()); +} + +/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it +/// included, so an object in it is picked and outlined there even when placing that object through +/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. +#[test] +fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() { + use vectorcraft_color::{Gradient, GradientPaint}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800])); + let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + big.extras_mut().mask = Some(Box::new(mask)); + let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]); + group.set_clips(true); + let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default())); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap(); + let m = d.symbol_slice_map("Corner", xf).unwrap(); + assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit"); + assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn"); + let n = d.node(inst).unwrap(); + assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1])); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap()); + assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}"); +} + +/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes, +/// which the canvas does not draw: a click over an original shape whose face the group erased +/// picks nothing, and a click over the face it shows picks the face. +#[test] +fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)); + let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art()); + let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + sources.name = Some(crate::shaper::SOURCES.into()); + sources.opacity = 0.0; + let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art()); + face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]); + group.extras_mut().shaper = Some(Box::default()); + let root = Node::group(d.alloc_id(), vec![Arc::new(group)]); + d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) }); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let n = d.node(inst).unwrap(); + let opt = HitOptions::default(); + assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape"); + assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bff8e40f3..5ab3b8bdf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument}; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; -pub use selection::{AnchorRef, SegmentRef, Selection}; +pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; @@ -121,6 +121,7 @@ pub use text::{ RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; +pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; @@ -141,7 +142,7 @@ mod limits; mod records; mod units; -pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; +pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; pub use canvas_range::BeyondCanvas; #[cfg(test)] use document_ext::free_id; @@ -150,7 +151,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; #[cfg(test)] use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; -pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit}; +pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -195,6 +196,9 @@ pub struct Document { /// Paragraph styles (besides the built-in [Normal Paragraph Style]). #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub para_styles: Shared>, + /// Type › Composite Fonts…: the composite fonts defined in the document, by name. + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub composite_fonts: Shared>, /// Threaded text: area-type frames one story flows through, in order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text_threads: Vec>, @@ -310,6 +314,10 @@ pub struct Document { /// given) SVG export links with `", "", + "", + "", + ] { + let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": xml }), visible: true }; + assert!(raster_effects(&[e]).is_empty(), "{xml}"); + } +} + +/// Scale Strokes & Effects scales the filter's lengths with the art: the points a unit measures. +#[test] +fn scaling_the_effect_scales_its_units() { + let mut e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": RED_FLOOD }), visible: true }; + crate::scale_effect(&mut e, 2.0); + assert_eq!(e.params["scale"], json!(2.0)); + crate::scale_effect(&mut e, 0.25); + assert_eq!(e.params["scale"], json!(0.5)); +} + +/// The form SVG output writes, for a filter that fills a 10 × 10 point box. +fn written(xml: &str) -> Option { + filter(json!({ "xml": xml })).written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)) +} + +/// SVG 1.1 output writes an image's reference as `xlink:href`; the filter as it is drawn and +/// listed keeps `href`. +#[test] +fn an_image_is_written_with_an_xlink_reference() { + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + let f = filter(json!({ "xml": format!(r#""#) })); + assert_eq!(f.xml(), format!(r#""#)); + let out = f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).unwrap(); + assert_eq!(out, format!(r#""#)); + assert!(f.writes_as_svg11()); + assert_eq!(check(&out), Ok("i".into()), "the written form reads back"); +} + +/// SVG 1.1 has no `feDropShadow`: it is written as the primitives Filter Effects gives as its +/// equivalent, with its defaults, input, result and subregion, and results no other primitive uses. +#[test] +fn a_drop_shadow_is_written_as_svg_1_1_primitives() { + assert_eq!( + written(r#""#).as_deref(), + Some(concat!( + r#""#, + r#""#, + )) + ); + // Its input, result, subregion, color space and flood; a name the filter uses is not taken. + let given = r#""#; + assert_eq!( + written(given).as_deref(), + Some(concat!( + r#""#, + r#""#, + r##""##, + r#""#, + r#""#, + )) + ); + // An implicit input, or a result no earlier primitive has, is the previous result, named. + for shadow in [r#""#, r#""#] { + let out = written(&format!(r#"{shadow}"#)).unwrap(); + assert!(out.starts_with(r##""#), "{out}"); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); + } + // What SVG 1.1 can't hold leaves the filter to be written as other pixel effects are. + for xml in [ + r#""#, + r#""#, + ] { + let f = filter(json!({ "xml": xml })); + assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{xml}"); + } + // An offset that isn't a number is the shadow's default, which an `feOffset` doesn't share. + let out = written(r#""#).unwrap(); + assert!(out.contains(r#"{}"#, "".repeat(13)); + assert!(!filter(json!({ "xml": many })).writes_as_svg11()); +} + +/// Each input is written as the primitive it reads when drawn: a result name only usvg gives (to +/// a primitive without a `result`, here the shadow), and the inputs usvg draws as `SourceGraphic`. +#[test] +fn inputs_are_written_as_the_primitives_they_read() { + let xml = r#""#; + let out = written(xml).unwrap(); + assert_eq!( + out, + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ) + ); + assert_eq!(check(&out), Ok("s".into()), "the written form reads back"); +} + +/// Other values SVG 1.1 lacks: SVG 1.1 values that draw alike, or no SVG 1.1 form. +#[test] +fn values_svg_1_1_lacks_are_written_as_resvg_draws_them() { + // `lighter` (Filter Effects) is drawn as the default `over`, and a blur has no `edgeMode`. + assert_eq!( + written(r#""#) + .as_deref(), + Some(r#""#) + ); + assert_eq!( + written(r#""#).as_deref(), + Some(r#""#) + ); + // CSS colors as `#rrggbb`, a flood color's alpha in its opacity, a lighting color's dropped. + assert_eq!( + written(r#""#) + .as_deref(), + Some( + r##""## + ) + ); + // Image scaling as an SVG 1.1 keyword. + let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2))); + for (given, kept) in + [("pixelated", "optimizeSpeed"), ("crisp-edges", "optimizeSpeed"), ("smooth", "optimizeQuality"), ("auto", "optimizeQuality")] + { + let out = written(&format!(r#""#)).unwrap(); + assert!(out.contains(&format!(r#"style="image-rendering:{kept}""#)), "{given}: {out}"); + } + // The blend modes SVG 1.1 lacks have no SVG 1.1 form. + for mode in LATER_BLEND_MODES { + let f = filter(json!({ "xml": format!(r#""#) })); + assert!(!f.writes_as_svg11(), "{mode}"); + } +} + +#[test] +fn base64_pads_as_rfc_4648_does() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!(base64(b"foobar"), "Zm9vYmFy"); +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ea3843f0e..1afa679aa 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -407,6 +407,140 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { assert_eq!(reshaped, 10); } +/// A path that keeps its shape because its Transform effect would pass the budget keeps the +/// gradients that paint the art as a whole in place too, also with Scale Strokes & Effects on. +#[test] +fn art_past_the_budget_keeps_its_own_gradients_in_place() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let id = NodeId(9); + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let circle = || Arc::new(Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + // The group's own fill, a gradient placed across the circle, after `carry` moves it. + let own_end = |carry: &crate::Carry| { + let mut ap = Appearance::default(); + ap.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + carry.apply(&mut ap); + match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + } + }; + for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] { + let grow = fx("distort.transform", params.clone()); + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS); + let end = own_end(&carry).unwrap(); + assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}"); + // No room beyond what the circle costs: scaled up, it would be drawn with more lines. + let mut art = Node::group(id, vec![circle()]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + let b = art.geometric_bounds().unwrap(); + assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}"); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed"); + // A group inside the art whose square took the effect moves its own gradient all the same. + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut inner = Node::group(NodeId(2), vec![square]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + let mut art = Node::group(id, vec![circle(), Arc::new(inner)]); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 }); + assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays"); + let inner = &art.children().unwrap()[1]; + let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") }; + assert!((g.geom.unwrap().end.x - 550.0).abs() < 1e-6, "{params}: the inner group's gradient: {:?}", g.geom); + } +} + +/// A hidden path in a layer that keeps its shape past the budget does not hold the layer's +/// gradients in place: the layer's bounds leave it out, so the layer's own gradient moves with +/// the visible art under a Transform effect and Free Distort, as does the art's when the layer is +/// the art. +#[test] +fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint}; + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let end = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + }; + // A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own. + let layer = || { + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut circle = Node::path(NodeId(4), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + circle.visible = false; + let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![square, Arc::new(circle)]; + } + layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + layer + }; + // No room beyond what the paths cost: the square takes each effect, the circle would be drawn + // with more lines and keeps its shape. + let budget = Budget { anchors: usize::MAX, lines: 0 }; + // Each scales the art ten times about its center: 0..100 becomes -450..550. + let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]}); + for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] { + let mut art = layer(); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + let circle = art.children().unwrap()[1].geometric_bounds().unwrap(); + assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id); + let square = art.children().unwrap()[0].geometric_bounds().unwrap(); + assert!((square.x1 - 550.0).abs() < 1e-6, "{}: the square takes the effect: {square:?}", grow.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id); + let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]); + crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id); + } +} + +/// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its +/// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the +/// art itself, as a placed gradient does. +#[test] +fn edited_freeform_points_follow_a_transform_effect_and_free_distort() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() }); + let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c); + ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() }); + assert!(ramp.geom.is_none() && !ramp.is_placed()); + let freeform = Paint::Gradient(Box::new(ramp)); + let xs = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at.x).collect::>()), + _ => None, + }; + let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap)); + // Each moves the art 30 pt right. + let corners = json!({"corners": [[0.3, 0.0], [1.3, 0.0], [1.3, 1.0], [0.3, 1.0]]}); + for e in [fx("distort.transform", json!({"moveH": 30})), fx("distort.freeDistort", corners)] { + let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]); + art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS); + let members = art.children().unwrap(); + assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id); + assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(xs(&own), Some(vec![30.0, 130.0]), "{}: the art's own points", e.id); + } +} + /// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, /// the Roughen built again with fewer points where they wouldn't. #[test] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eddf966db..0ebcb71c5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,10 +9,14 @@ description = "VectorCraft engine: sessions, history, selection, the command reg workspace = true [features] -default = ["bundled-fonts"] +default = ["bundled-fonts", "heif"] # The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at # run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`. bundled-fonts = ["vectorcraft-text/bundled-fonts"] +# HEIC and HEIF images open and place, decoded by heic-rs (an HEVC decoder). A build without this +# feature still recognizes the files and refuses them with a message. The apps list it beside +# `bundled-fonts`. +heif = ["dep:heic-rs", "dep:rayon"] [dependencies] # Which paths stored in documents may be opened. @@ -37,6 +41,8 @@ vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } +# Pure Rust, no unsafe; pinned at the version photocraft-heif uses. +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true } flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). @@ -54,6 +60,11 @@ log = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["std", "process"] } +# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true } +rayon = { version = "1.12", optional = true } + [dev-dependencies] vectorcraft-affinity = { workspace = true, features = ["synth"] } crc32fast = { workspace = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a26c75da2..75835394a 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow Illustrator's menu structure. +pub mod actionset; mod addons; pub(crate) mod appearance; mod assets; @@ -11,6 +12,7 @@ pub mod clipboard; mod colorcmds; pub mod colormgmt; pub mod colortheme; +pub mod compositefonts; mod compoundshape; pub(crate) mod create; mod css; @@ -83,6 +85,7 @@ pub(crate) mod slices; mod stroke; mod style; pub mod stylelib; +mod svgfilter; mod swatch; pub mod swatchlib; pub(crate) mod symboledit; @@ -284,6 +287,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); + v.extend(compositefonts::specs()); v.extend(fontfiles::specs()); v.extend(help::specs()); v.extend(threads::specs()); @@ -317,6 +321,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(expand::specs()); v.extend(attributes::specs()); v.extend(interactivity::specs()); + v.extend(svgfilter::specs()); v.extend(newart::specs()); v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); @@ -344,6 +349,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(halftone::specs()); v.extend(perspgrid::specs()); v.extend(shortcutset::specs()); + v.extend(actionset::specs()); v.extend(importmatches::specs()); v.extend(addons::specs()); v @@ -416,8 +422,22 @@ pub(crate) fn text_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Option { p.get(key).and_then(Value::as_u64).map(NodeId) } -pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { - p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) +/// The object ids `p` gives for `key`, or `None` when `p` leaves it out or gives null. A value that +/// is not an array, or an entry that is not a non-negative integer, is an error with the message +/// [`checked_ids_param`] gives, so no entry is dropped. Whether each id names an object is left to +/// the caller. +pub(crate) fn ids_param(p: &Value, key: &str, cmd: &str) -> Result>> { + let values = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(a)) => a, + Some(_) => return Err(bad(cmd, format!("{key} must be an array of non-negative integer object ids"))), + }; + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, cmd)).collect::>>().map(Some) +} +/// Entry `index` (from 0) of the `key` list as an object id: anything but a non-negative integer is +/// an error naming the key, the entry and its value. +fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result { + value.as_u64().map(NodeId).ok_or_else(|| bad(cmd, format!("`{key}` entry {index} is {value}, not a non-negative integer object id"))) } /// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads /// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error, @@ -443,7 +463,7 @@ pub(crate) fn index(v: u64) -> usize { pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { let values = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; - values.iter().map(|value| checked_id(s, value, command)).collect() + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect() } /// The objects a command acts on: `ids` when given (every one checked, and those inside another @@ -590,11 +610,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result Result> { match named_ids(p)? { Some(ids) => Ok(ids), - None => Ok(s.doc()?.selection.objects.clone()), + None => { + edit::refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + } } } @@ -667,6 +692,7 @@ pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; +pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/actionset.rs b/crates/engine/src/cmd/actionset.rs new file mode 100644 index 000000000..b6a911838 --- /dev/null +++ b/crates/engine/src/cmd/actionset.rs @@ -0,0 +1,560 @@ +//! Action sets another app saves as text (`.aia` files that start `/version`), read into their +//! actions and steps without changing anything (`actionSet.read`). +//! +//! The text is a set of fields and blocks. A slash name followed by a value sets a field, and a +//! slash name followed by `{` opens a block that `}` closes. A value is a decimal number, text in +//! parentheses, a byte count and hexadecimal bytes in square brackets (`[ 5 48656c6c6f ]`, UTF-8 +//! text, which can run over several words), or a byte count and hexadecimal bytes in angle brackets +//! (`< 3 0a0b0c >`, raw bytes). The set's `/version` comes first, and its `/name` is hexadecimal +//! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N` +//! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with +//! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was +//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal +//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount` +//! and a step's `/parameterCount` give how many actions, steps and recorded values follow. +//! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting +//! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post +//! "Made a two-way aia to json converter" +//! (https://community.adobe.com/t5/illustrator-discussions/made-a-two-way-aia-to-json-converter/m-p/13751909), +//! sttk3's article on names as UTF-8 hexadecimal +//! (https://sttk3.com/blog/tips/illustrator/dynamic-generate-action.html), and the MIT-licensed +//! reader in zachshallbetter/adobe-mcp (https://github.com/zachshallbetter/adobe-mcp). The raw form +//! follows the forum thread "Creating a dynamic action" +//! (https://community.adobe.com/questions-652/creating-a-dynamic-action-to-use-with-app-doscript-method-814406). + +use serde_json::{Value, json}; + +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, text_param}; + +/// The most bytes of a file read. +const MAX_BYTES: usize = 8 << 20; +/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text +/// goes on. +const READ_LIMIT: usize = MAX_BYTES + 4; +/// The most blocks open at once. +const MAX_DEPTH: usize = 16; +/// The most actions read. +const MAX_ACTIONS: usize = 1000; +/// The most steps read in one action. +const MAX_STEPS: usize = 10_000; +/// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and +/// the answer holds an object for each. +const MAX_SET_STEPS: usize = 100_000; +/// The longest name kept (bytes). +const MAX_NAME: usize = 1024; +/// The most tokens listed as unread. +const MAX_UNREAD: usize = 1000; +/// The longest unread token shown (characters). +const UNREAD_SHOWN: usize = 80; + +/// The extensions of action sets other apps save. +pub const EXTS: &[&str] = &["aia"]; + +pub fn specs() -> Vec { + vec![cmd!( + query "actionSet.read", + "Read Action Set File", + [], + None, + "{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error", + always, + read + )] +} + +/// A step of an action: an `/event-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaStep { + /// Its `/internalName`. + pub name: String, + /// Its `/localizedName`, if it has one. + pub label: Option, + /// Its `/isOn`, if it has one. + pub on: Option, + /// How many `/parameter-N` blocks it has. + pub params: usize, + /// Its `/parameterCount`, if it has one. + pub declared: Option, + /// The line its block opens on (from 1). + pub line: usize, +} + +/// An action: an `/action-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaAction { + pub name: String, + pub steps: Vec, + /// Its `/eventCount`, if it has one. + pub declared: Option, +} + +/// An action set read from text. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaSet { + pub version: i64, + /// The set's `/name`, if it has one. + pub name: Option, + pub actions: Vec, + /// Its `/actionCount`, if it has one. + pub declared: Option, + /// The tokens that weren't read, with their lines. + pub unread: Vec<(usize, String)>, + /// A cap stopped the reading or cut a name, the text ends inside a block or a value or after a + /// field's name, its counts differ from the actions, steps and recorded values read (a negative + /// count, one too large for an `i64` and one that isn't a number always differ), or a hexadecimal + /// name, label or recorded `/value` isn't as many bytes as its count says. + pub truncated: bool, + /// How many steps were read in all the actions. + steps: usize, +} + +/// A token of the text. +enum Token { + /// A slash name. + Name(Vec), + /// An integer; `None` when it doesn't fit an `i64`. + Int(Option), + /// Text in parentheses, a backslash taking the next byte as it is. + Text(Vec), + /// The bytes of `[ count hex… ]`; `None` when the hexadecimal digits don't make whole bytes or + /// as many bytes as the count says. + Hex(Option>), + /// `< count hex… >`. + Raw, + Open, + Close, + /// Anything else, as written. + Other(Vec), +} + +/// The tokens of `text`, each with its line. +struct Tokens<'a> { + text: &'a [u8], + at: usize, + line: usize, + /// The text ended inside a value in parentheses, square brackets or angle brackets. + unclosed: bool, +} + +impl<'a> Tokens<'a> { + /// Past one byte, counting a line at LF, at CR alone, and once for CRLF. + fn step(&mut self) -> Option { + let b = *self.text.get(self.at)?; + if b == b'\n' || b == b'\r' && self.text.get(self.at + 1) != Some(&b'\n') { + self.line += 1; + } + self.at += 1; + Some(b) + } + + /// The bytes up to `end` (not taken in); `None` when the text ends first. + fn until(&mut self, end: u8) -> Option<&'a [u8]> { + let (text, start) = (self.text, self.at); + while text.get(self.at).is_some_and(|&b| b != end) { + self.step(); + } + let inside = text.get(start..self.at)?; + text.get(self.at)?; + self.at += 1; + Some(inside) + } + + fn next_token(&mut self) -> Option<(usize, Token)> { + while self.text.get(self.at).is_some_and(|&b| b.is_ascii_whitespace() || b == 0) { + self.step(); + } + let line = self.line; + let b = *self.text.get(self.at)?; + let ends = |b: u8| b.is_ascii_whitespace() || b == 0 || b"{}[]()<>/".contains(&b); + let token = match b { + b'{' | b'}' => { + self.at += 1; + if b == b'{' { Token::Open } else { Token::Close } + } + b'(' => { + self.at += 1; + let (mut text, mut depth) = (vec![], 0usize); + self.unclosed = true; + while let Some(c) = self.step() { + match c { + b'\\' => text.extend(self.step()), + b'(' => { + depth += 1; + text.push(c); + } + b')' if depth == 0 => { + self.unclosed = false; + break; + } + b')' => { + depth -= 1; + text.push(c); + } + _ => text.push(c), + } + } + Token::Text(text) + } + b'[' | b'<' => { + self.at += 1; + let close = if b == b'[' { b']' } else { b'>' }; + let inside = self.until(close); + self.unclosed = inside.is_none(); + let inside = inside.unwrap_or_default().to_vec(); + if b == b'<' { + Token::Raw + } else { + // The first word is the byte count; the bytes follow in one or more words. + let mut words = inside.split(|c| c.is_ascii_whitespace()).filter(|w| !w.is_empty()); + let count = words.next().and_then(|w| std::str::from_utf8(w).ok()).and_then(|w| w.parse::().ok()); + let digits: Vec = words.flatten().copied().collect(); + Token::Hex(hex_bytes(&digits).filter(|b| Some(b.len()) == count)) + } + } + b'/' => { + self.at += 1; + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + Token::Name(self.text.get(start..self.at).unwrap_or_default().to_vec()) + } + _ => { + let start = self.at; + while self.text.get(self.at).is_some_and(|&c| !ends(c)) { + self.at += 1; + } + // Always past at least one byte. + self.at = self.at.max(start + 1); + let word = self.text.get(start..self.at).unwrap_or_default(); + let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word); + if !number.is_empty() && number.iter().all(u8::is_ascii_digit) { + Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok())) + } else { + Token::Other(word.to_vec()) + } + } + }; + Some((line, token)) + } +} + +/// The bytes hexadecimal `digits` stand for, two digits a byte. +fn hex_bytes(digits: &[u8]) -> Option> { + let nibble = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + if !digits.len().is_multiple_of(2) { + return None; + } + digits.chunks(2).map(|pair| Some((nibble(*pair.first()?)? << 4) | nibble(*pair.get(1)?)?)).collect() +} + +/// What a block is. +enum Kind { + Action, + Step, + Param, + /// A block that isn't read, or one past a cap. + Other, +} + +/// `name` ends with `-` and a number after `prefix`, as `/action-1` does. +fn numbered(name: &[u8], prefix: &[u8]) -> bool { + name.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.iter().all(u8::is_ascii_digit)) +} + +/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters. +fn name_text(name: &[u8], set: &mut AiaSet) -> String { + if name.len() > MAX_NAME { + set.truncated = true; + } + let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned(); + // A character cut at the limit reads as one replacement character. + if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') { + s.pop(); + } + s +} + +/// How the unread list shows a value in square brackets that isn't as many bytes as its count says. +const BAD_HEX: &str = "[ … ] (not the hexadecimal bytes its count says)"; + +/// List a token that isn't read. +fn unread(set: &mut AiaSet, line: usize, text: impl AsRef<[u8]>) { + if set.unread.len() < MAX_UNREAD { + let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect(); + set.unread.push((line, text)); + } +} + +/// What the block `name` that opens on `line` inside the blocks `stack` is: a new action, step or +/// recorded value of `set`, or a block that isn't read (listed when it is out of place). +fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kind { + match stack.last() { + _ if stack.len() >= MAX_DEPTH => { + set.truncated = true; + Kind::Other + } + None if numbered(name, b"action-") => { + if set.actions.len() < MAX_ACTIONS { + set.actions.push(AiaAction::default()); + Kind::Action + } else { + set.truncated = true; + Kind::Other + } + } + Some(Kind::Action) if numbered(name, b"event-") => match set.actions.last_mut() { + Some(a) if a.steps.len() < MAX_STEPS && set.steps < MAX_SET_STEPS => { + a.steps.push(AiaStep { line, ..AiaStep::default() }); + set.steps += 1; + Kind::Step + } + _ => { + set.truncated = true; + Kind::Other + } + }, + Some(Kind::Step) if numbered(name, b"parameter-") => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.params += 1; + } + Kind::Param + } + Some(Kind::Param | Kind::Other) => Kind::Other, + _ => { + unread(set, line, format!("/{} {{", String::from_utf8_lossy(name))); + Kind::Other + } + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the hexadecimal text `bytes` +/// on `line`: the set's or an action's `/name`, or a step's `/localizedName`. +fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Option>, line: usize) { + let Some(bytes) = bytes else { + set.truncated = true; + unread(set, line, BAD_HEX); + return; + }; + match (kind, name) { + (None, Some(b"name")) => set.name = Some(name_text(&bytes, set)), + (Some(Kind::Action), Some(b"name")) => { + let text = name_text(&bytes, set); + if let Some(a) = set.actions.last_mut() { + a.name = text; + } + } + (Some(Kind::Step), Some(b"localizedName")) => { + let text = name_text(&bytes, set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.label = Some(text); + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, "[ … ]"), + } +} + +/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count +/// says. +fn check_value(set: &mut AiaSet, bytes: Option>, line: usize) { + if bytes.is_none() { + set.truncated = true; + unread(set, line, BAD_HEX); + } +} + +/// Mark `set` truncated when the field `name` of the block `kind` (the set when `None`) is a count +/// (the set's `/actionCount`, an action's `/eventCount` or a step's `/parameterCount`) whose value is +/// not a number that fits an `i64`. +fn bad_count(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>) { + if matches!((kind, name), (None, Some(b"actionCount")) | (Some(Kind::Action), Some(b"eventCount")) | (Some(Kind::Step), Some(b"parameterCount"))) + { + set.truncated = true; + } +} + +/// Set the field `name` of the block `kind` (the set when `None`) to the integer `value` on `line`: +/// the set's `/actionCount`, an action's `/eventCount`, a step's `/parameterCount`, or a step's +/// `/isOn`. A count too large for an `i64` marks the set truncated. +fn set_int(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, value: Option, line: usize) { + if value.is_none() { + bad_count(set, kind, name); + } + match (kind, name) { + (None, Some(b"actionCount")) => set.declared = value, + (Some(Kind::Action), Some(b"eventCount")) => { + if let Some(a) = set.actions.last_mut() { + a.declared = value; + } + } + (Some(Kind::Step), Some(b"parameterCount")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.declared = value; + } + } + (Some(Kind::Step), Some(b"isOn")) => { + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.on = value; + } + } + (_, Some(_)) => {} + (_, None) => unread(set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())), + } +} + +/// Read an action set saved as text. An error when the text isn't one: it doesn't start with +/// `/version` and an integer. +pub fn read_aia(bytes: &[u8]) -> std::result::Result { + let mut set = AiaSet::default(); + let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + let text = match text.get(..MAX_BYTES) { + Some(head) if text.len() > MAX_BYTES => { + set.truncated = true; + head + } + _ => text, + }; + let mut tokens = Tokens { text, at: 0, line: 1, unclosed: false }; + match (tokens.next_token(), tokens.next_token()) { + (Some((_, Token::Name(n))), Some((_, Token::Int(Some(v))))) if n == b"version" => set.version = v, + _ => return Err("not an action set: the text doesn't start with /version and a number".into()), + } + let mut stack: Vec = vec![]; + // A slash name waiting for its value or its `{`: (line, name). + let mut pending: Option<(usize, Vec)> = None; + while let Some((line, token)) = tokens.next_token() { + let in_step = matches!(stack.last(), Some(Kind::Step)); + let field = |pending: &mut Option<(usize, Vec)>| pending.take().map(|(_, n)| n); + match token { + Token::Name(name) => { + if let Some((l, n)) = pending.replace((line, name)) { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + } + Token::Open => { + let (l, name) = pending.take().unwrap_or((line, vec![])); + bad_count(&mut set, stack.last(), Some(&name)); + let kind = open_block(&mut set, &stack, &name, l); + stack.push(kind); + } + Token::Close => { + if let Some((l, n)) = pending.take() { + bad_count(&mut set, stack.last(), Some(&n)); + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + if stack.pop().is_none() { + unread(&mut set, line, "}"); + } + } + Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => { + if field(&mut pending).as_deref() == Some(b"value".as_slice()) { + check_value(&mut set, bytes, line); + } + } + // The other values inside a recorded value, or a block that isn't read. + _ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => { + pending = None; + } + Token::Hex(bytes) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + set_hex(&mut set, stack.last(), name.as_deref(), bytes, line); + } + Token::Text(t) => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + match name { + Some(n) if in_step && n == b"internalName" => { + let name = name_text(&t, &mut set); + if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { + s.name = name; + } + } + Some(_) => {} + None => unread(&mut set, line, [b"(".as_slice(), &t, b")"].concat()), + } + } + Token::Int(v) => { + let name = field(&mut pending); + set_int(&mut set, stack.last(), name.as_deref(), v, line); + } + Token::Raw => { + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, "< … >"); + } + } + Token::Other(word) => { + // A real number, or another word as a field's value. + let name = field(&mut pending); + bad_count(&mut set, stack.last(), name.as_deref()); + if name.is_none() { + unread(&mut set, line, word); + } + } + } + } + // The text ends after a field's name, before its value. + if let Some((l, n)) = pending { + set.truncated = true; + unread(&mut set, l, [b"/".as_slice(), &n].concat()); + } + // An unclosed value is the last token: it takes the rest of the text. + if tokens.unclosed { + set.truncated = true; + unread(&mut set, tokens.line, "a value not closed at the end"); + } + if !stack.is_empty() { + set.truncated = true; + unread(&mut set, tokens.line, format!("blocks left open at the end: {}", stack.len())); + } + // Not as many actions, steps or recorded values as the counts give; a negative count never matches. + let differs = |declared: Option, read: usize| declared.is_some_and(|d| !usize::try_from(d).is_ok_and(|d| d == read)); + let step_differs = |a: &AiaAction| a.steps.iter().any(|s| differs(s.declared, s.params)); + if differs(set.declared, set.actions.len()) || set.actions.iter().any(|a| differs(a.declared, a.steps.len()) || step_differs(a)) { + set.truncated = true; + unread(&mut set, tokens.line, "not as many actions, steps or recorded values as the counts give"); + } + Ok(set) +} + +fn read(_: &mut Session, p: &Value) -> Result { + const C: &str = "actionSet.read"; + let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let name = text_param(p, "name", C)?; + let bytes: std::borrow::Cow<[u8]> = match (path, data, b64) { + (Some(path), None, None) => super::fileio::read_file_head(path, READ_LIMIT)?.into(), + (None, Some(text), None) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(), + (None, None, Some(b64)) => super::scripts::decode_head(b64, READ_LIMIT).ok_or_else(|| bad(C, "bad dataBase64"))?.into(), + _ => return Err(bad(C, "give exactly one of `path`, `data` and `dataBase64`")), + }; + let set = read_aia(&bytes).map_err(EngineError::Other)?; + let file = name.or(path).unwrap_or_default(); + let stem = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = set.name.clone().filter(|n| !n.trim().is_empty()).unwrap_or(stem); + let actions: Vec = set + .actions + .iter() + .map(|a| { + let steps: Vec = a + .steps + .iter() + .map(|s| { + let label = s.label.clone().filter(|l| !l.trim().is_empty()); + json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line }) + }) + .collect(); + json!({ "name": a.name, "steps": steps }) + }) + .collect(); + Ok(json!({ + "set": name, + "version": set.version, + "actions": actions, + "unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::>(), + "truncated": set.truncated, + })) +} diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 4b76562e9..73b21356b 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -962,11 +962,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let default = CharStyle::default().font_family; let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); let db = FontDb::global(); - // Any name of the family is stored as its own name. - let family = match db.resolve(wanted, "Regular") { - Some((f, m)) if m != FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, "the replacement font is not available")), - }; + // Any name of the family is stored as its own name, and a composite font as its own name. + let composite = db.composite_font(wanted).is_some(); + let family = db.stored_family(wanted, "Regular").ok_or_else(|| bad(C, "the replacement font is not available"))?; let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style)); let d = &s.doc()?.doc; @@ -988,8 +986,11 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let mut runs = 0; let mut replace = |t: &mut TextObject| { for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { - // The style the font has closest to the one the character had. - r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + // The style the font has closest to the one the character had (a composite font's + // parts name their own styles: the character keeps its style). + if !composite { + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + } r.style.font_family = family.clone(); r.style.font_version = None; r.style.font_axes.clear(); @@ -1004,7 +1005,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); - drop(replace); Ok(runs) })?; Ok(json!({ "runs": runs, "font": family })) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index 574003b93..cd30d29d1 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -174,7 +174,7 @@ fn filter(s: &mut Session, p: &Value) -> Result { let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…')); let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc); let swatches = |name: &str| kinds.get(name).copied(); - recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) + recolor_links(s, &given, C, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) } /// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own diff --git a/crates/engine/src/cmd/addons/color/nearest.rs b/crates/engine/src/cmd/addons/color/nearest.rs index 5a9d0e7c7..0236698be 100644 --- a/crates/engine/src/cmd/addons/color/nearest.rs +++ b/crates/engine/src/cmd/addons/color/nearest.rs @@ -189,7 +189,7 @@ pub(super) fn apply(s: &mut Session, p: &Value) -> Result { let given = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false, }); - let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; + let r = recolor_links(s, &given, APPLY, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; squash(s, undo, "Apply Nearest Swatch"); Ok(json!({ "changed": r["changed"], "swatches": names, "added": added })) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 30e48d4fc..02990a662 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -598,6 +598,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { n.appearance.items.push(item); added.push((*id, fill)); } + if !fill { + super::object::snap_if_aligned(n); + } } } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -620,6 +623,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { for id in &ids { if let Some(n) = d.node_mut(*id) { clear(n); + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); @@ -661,6 +665,35 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { + let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) + }; + let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); + painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind))) + }; + let mut opacity_rows = top_opacity_changed(before, d, ids); + opacity_rows.extend(written(d, opacities)); + let mut rows = top_paint_changed(before, d, ids); + rows.extend(written(d, paints)); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + kept || painted +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); @@ -698,9 +731,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { } to.appearance = ap; (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); + super::object::snap_if_aligned(to); if !copy && let Some(n) = d.node_mut(source) { clear(n); (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + super::object::snap_if_aligned(n); } let changed = if copy { vec![target] } else { vec![source, target] }; let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); @@ -759,6 +794,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { st.blend = BlendMode::Normal; n.appearance.items.push(AppearanceItem::Stroke(st)); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); @@ -818,6 +854,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("visible").and_then(Value::as_bool) { *vis = v; } + super::object::snap_if_aligned(n); let items = &n.appearance.items; if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx)) @@ -878,6 +915,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { for &i in idx.iter().rev() { n.appearance.remove_item(i); } + super::object::snap_if_aligned(n); } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); @@ -982,7 +1020,19 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { - let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); + let shown: Vec = roots + .iter() + .copied() + .filter(|id| { + d.node_mut(*id).is_some_and(|n| { + let shown = n.appearance.show_all(); + if shown { + super::object::snap_if_aligned(n); + } + shown + }) + }) + .collect(); if shown.is_empty() { return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); } @@ -1136,6 +1186,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + // An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked + // object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A + // gradient or pattern sampled for it, a source that is not a path, compound path or type (a + // symbol instance's own appearance is empty), and options that pick up neither color are + // refused, and the paint defaults stay as they are. A path of a compound path stands for the + // compound path, which holds the paint. + if !reverse + && !append + && !defaults_only + && p.get("ids").is_none() + && let Some((id, path)) = super::brushsym::selected_part(s)? + { + let d = &s.doc()?.doc; + let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone()); + let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None)); + let a = attrs.appearance; + if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src) + || !(a.fill.color || a.stroke.color) + || (a.fill.color && !super::paint::part_takes(&fill)) + || (a.stroke.color && !super::paint::part_takes(&stroke)) + { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| { + if a.fill.color { + o.fill = Some(fill); + } + if a.stroke.color { + o.stroke = Some(stroke); + } + })?; + // The clicked object becomes the Eyedropper's sample, and new art takes its paint. + Picked::of(&src).to_defaults(s, &attrs.appearance); + s.eyedropper_sample = Some(src); + return Ok(json!({ "ids": [id.0] })); + } let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { @@ -1144,7 +1230,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let selected = if defaults_only { vec![] } else { - match ids_param(p, "ids") { + match ids_param(p, "ids", C)? { Some(v) => outermost(&s.doc()?.doc, v), None => selected_roots(s)?, } @@ -1343,6 +1429,7 @@ impl Picked { let placed = self.placed(n.geometric_bounds()); n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color })); n.appearance.effects.extend(placed.effects); + super::object::snap_if_aligned(n); return; } if let NodeKind::Text(t) = &mut n.kind { @@ -1366,6 +1453,7 @@ impl Picked { } else { focal_attrs(&mut n.appearance, &placed, ap); } + super::object::snap_if_aligned(n); } } diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 133fb1fce..8c225b877 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -3,12 +3,17 @@ //! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects //! the selection this way and opens Export for Screens on its Assets tab (a UI command). +use std::collections::HashSet; + use serde_json::{Map, Value, json}; use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; -use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; +use super::fileio::{ + DEFAULT_FORMAT_3D, FORMATS_3D, SCREEN_SETTINGS_KEYS, check_screen_settings, create_dir, export_crop, export_screens, mesh_file_names, mesh_files, + safe_file_name, store_screen_settings, with_screen_export_preset, write_file, +}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param, switch_or, text_param}; /// The longest asset name kept (characters). const MAX_NAME: usize = 255; @@ -39,7 +44,7 @@ pub fn specs() -> Vec { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", has_doc, list ), @@ -48,10 +53,37 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), + cmd!( + "assets.add3D", + "Export 3D Object", + [], + None, + "{ids?: [id…] (default: the selected objects), format?: usda|usdz|gltf|obj (default usda)} collect each object with an Extrude & Bevel, Revolve, Inflate or Rotate effect as a 3D asset of Window › Asset Export, named after the object (else Asset 1, Asset 2…): assets.export3D writes it as a mesh file. An object already collected as a 3D asset keeps it; an object without such an effect is refused → {assets: [asset id…] (in order), added: n}", + has_doc, + add_3d + ), + cmd!( + "assets.set3DFormat", + "3D Export Format", + [], + None, + "{assets: [asset id…], format: usda|usdz|gltf|obj} the mesh format 3D assets export to (Asset Export's Format for a 3D asset) → {changed: n}", + has_doc, + set_3d_format + ), + cmd!( + "assets.export3D", + "Export 3D Assets", + [], + None, + "{assets?: [asset id…] (default every 3D asset), folder?, zip?} each 3D asset's object as a mesh file in its format (assets.set3DFormat), named after the asset: USDA or USDZ, glTF 2.0 (one .gltf, its buffer embedded) or OBJ with its .mtl (whitespace and # in an OBJ's name become _). Every solid of the object (the fill's, each stroke's, a group's members') is a mesh with a material in the color it is painted; faces turned away from the viewer are included, at the object's 3D rotation, y up, about the point it turns about, in points (glTF: meters). Files that would share a name get -2, -3…; an object with a hole that crosses its face's outline or another hole, or with more than 2,000,000 triangles, is refused → {files: [path…]} (no folder: {files: [{name, dataBase64}]}, or with zip one zip as {name, dataBase64, files})", + has_doc, + export_3d + ), cmd!( "assets.settings.set", "Asset Export Settings", @@ -115,7 +147,9 @@ fn add(s: &mut Session, p: &Value) -> Result { if name.is_some() && sets.len() > 1 { return Err(bad(C, "a name names one asset: give multiple: false, or one object")); } - let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id); + // A 3D asset of the same art is another asset. + let existing = + |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes && a.format_3d.is_none()).map(|a| a.id); let d = &s.doc()?.doc; // Nothing new: no undo step. if sets.iter().all(|set| existing(d, set).is_some()) { @@ -134,7 +168,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name()); let id = d.alloc_id().0; - d.assets.push(ExportAsset { id, name, nodes }); + d.assets.push(ExportAsset { id, name, nodes, format_3d: None }); out.push(id); added += 1; } @@ -142,6 +176,161 @@ fn add(s: &mut Session, p: &Value) -> Result { }) } +/// The format of param `format` of command `cmd` ([`FORMATS_3D`]), `default` when it is not given. +fn format_3d_param(p: &Value, cmd: &str, default: Option<&str>) -> Result { + match (p.get("format"), default) { + (None, Some(d)) => Ok(d.to_string()), + (Some(Value::String(f)), _) if FORMATS_3D.contains(&f.as_str()) => Ok(f.clone()), + _ => { + let key = "format"; + Err(bad(cmd, format!("`{key}` must be one of {}", FORMATS_3D.join(", ")))) + } + } +} + +/// The asset ids of param `key` of command `cmd`, each a 3D asset of `d`. +fn three_d_asset_ids(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result> { + let ids = asset_ids(p, key, cmd)?; + for id in &ids { + match d.asset(*id) { + None => return Err(bad(cmd, "no such assets (see assets.list)")), + Some(a) if a.format_3d.is_none() => return Err(bad(cmd, format!("asset {id} isn't a 3D asset"))), + _ => {} + } + } + Ok(ids) +} + +fn add_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.add3D"; + let format = format_3d_param(p, C, Some(DEFAULT_FORMAT_3D))?; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? }; + if ids.is_empty() { + return Err(bad(C, "select an object with a 3D effect (or give its ids)")); + } + let d = &s.doc()?.doc; + let mut ids = d.paint_order(ids); + ids.dedup(); + for id in &ids { + let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?; + if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) { + let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0)); + return Err(bad(C, format!("{what} can't be exported in 3D: {e}"))); + } + } + let existing = |d: &vectorcraft_doc::Document, id: NodeId| d.assets.iter().find(|a| a.nodes == [id] && a.format_3d.is_some()).map(|a| a.id); + // Nothing new: no undo step. + if ids.iter().all(|id| existing(d, *id).is_some()) { + return Ok(json!({ "assets": ids.iter().filter_map(|id| existing(d, *id)).collect::>(), "added": 0 })); + } + s.edit("Export 3D Object", |d, _| { + let (mut out, mut added) = (vec![], 0); + for node in ids { + if let Some(id) = existing(d, node) { + out.push(id); + continue; + } + let name = d.node(node).and_then(|n| n.name.as_deref()).and_then(clean_name).unwrap_or_else(|| d.next_asset_name()); + let id = d.alloc_id().0; + d.assets.push(ExportAsset { id, name, nodes: vec![node], format_3d: Some(format.clone()) }); + out.push(id); + added += 1; + } + Ok(json!({ "assets": out, "added": added })) + }) +} + +fn set_3d_format(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.set3DFormat"; + let d = &s.doc()?.doc; + let ids = three_d_asset_ids(d, p, "assets", C)?; + let format = format_3d_param(p, C, None)?; + let changed = d.assets.iter().filter(|a| ids.contains(&a.id) && a.format_3d.as_deref() != Some(format.as_str())).count(); + // Nothing changes: no undo step. + if changed == 0 { + return Ok(json!({ "changed": 0 })); + } + s.edit("3D Export Format", |d, _| { + for a in d.assets.iter_mut().filter(|a| ids.contains(&a.id)) { + a.format_3d = Some(format.clone()); + } + Ok(json!({ "changed": changed })) + }) +} + +fn export_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.export3D"; + let d = &s.doc()?.doc; + let ids = if p.get("assets").is_some_and(|v| !v.is_null()) { + three_d_asset_ids(d, p, "assets", C)? + } else { + d.assets.iter().filter(|a| a.format_3d.is_some()).map(|a| a.id).collect() + }; + if ids.is_empty() { + return Err(bad(C, "no 3D assets yet: collect an object with a 3D effect first (assets.add3D)")); + } + let folder = text_param(p, "folder", C)?.filter(|f| !f.is_empty()); + let zip = switch_or(p, "zip", false, C)?; + // Every file's name, compared without case: a later file that would take one gets -2, -3… + let mut taken: HashSet = HashSet::new(); + let mut files: Vec<(String, Vec)> = vec![]; + for id in ids { + let Some(a) = d.asset(id) else { continue }; + let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); + let nodes = + a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::>>()?; + // The asset's objects share one budget of triangles. + let what = &a.name; + let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; + let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); + let mut stem = base.clone(); + let mut k = 1; + loop { + let names = mesh_file_names(&stem, format).map_err(|e| bad(C, e))?; + if names.iter().all(|n| !taken.contains(&n.to_lowercase())) { + break; + } + k += 1; + stem = format!("{base}-{k}"); + } + let made = mesh_files(&stem, format, &parts).map_err(|e| bad(C, e))?; + taken.extend(made.iter().map(|(n, _)| n.to_lowercase())); + files.extend(made); + } + let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect(); + if zip { + let archive = super::fileio::zip::store(&files).map_err(|e| bad(C, e))?; + // "-3D" keeps it apart from the picture assets' ZIP, which is named after the document. + let name = Some(safe_file_name(&d.title)).filter(|t| !t.is_empty()).map_or_else(|| "3D Assets.zip".into(), |t| format!("{t}-3D.zip")); + return match folder { + Some(dir) => { + create_dir(dir)?; + let path = format!("{dir}/{name}"); + write_file(&path, &archive)?; + Ok(json!({ "path": path, "bytes": archive.len(), "files": names })) + } + None => Ok(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })), + }; + } + match folder { + Some(dir) => { + create_dir(dir)?; + let mut written = vec![]; + for (name, bytes) in &files { + let path = format!("{dir}/{name}"); + write_file(&path, bytes)?; + written.push(path); + } + Ok(json!({ "files": written })) + } + None => { + let files: Vec = + files.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect(); + Ok(json!({ "files": files })) + } + } +} + /// The asset ids of param `key`. fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result> { let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?; @@ -186,6 +375,7 @@ fn list(s: &mut Session, _: &Value) -> Result { "name": a.name, "nodes": a.nodes.iter().map(|n| n.0).collect::>(), "bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]), + "format3D": a.format_3d, }) }) .collect(); @@ -227,8 +417,10 @@ fn export(s: &mut Session, p: &Value) -> Result { if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() { q.insert("folders".into(), json!("none")); } + let d = &s.doc()?.doc; + // 3D assets are written as mesh files, by assets.export3D: the export refuses one it is given. if !q.contains_key("assets") { - let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); + let all: Vec = d.assets.iter().filter(|a| a.format_3d.is_none()).map(|a| a.id).collect(); if all.is_empty() { return Err(bad(C, "no assets yet: collect art for export first (assets.add)")); } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 8cf3a2195..53f373a3b 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -36,7 +36,8 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +pub(crate) use dynamic::{edit_part, part_only, selected_part}; +use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, @@ -262,10 +263,19 @@ pub fn specs() -> Vec { "Symbol Instance Appearance", [], None, - "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}", has_selection, symbol_instance_set_appearance ), + cmd!( + "symbol.instance.selectPart", + "Select Symbol Instance Part", + [], + None, + "{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}", + has_doc, + symbol_instance_select_part + ), cmd!( "symbol.instance.reset", "Reset Symbol Instance", diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index baa721c45..89c7c3c52 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc Result)>> { + Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone()))) +} + +/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo +/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many +/// objects the instance changes. +pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result { + let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone(); + let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() }); + let mut new = old.clone(); + f(&mut new); + if new == old { + return Ok(list.len()); + } + s.edit(label, |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + }) +} + +/// The refusal of anything but a solid color fill or stroke change while an object of a symbol +/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art. +pub(crate) fn part_only() -> EngineError { + EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into()) +} + +pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.selectPart"; + if p.get("id").is_none() { + return Err(bad(C, "missing id")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + // Direct Selection passes over hidden, locked and template objects, and over everything in a + // group or layer that is: the instance in the document, and the object in the symbol's art. + let editable = |n: &Node| n.visible && !n.locked && !n.is_template(); + if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) { + return Err(bad(C, "the object is hidden or locked")); + } + if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) { + return Err(part_only()); + } + let shown = json!(path); + s.select(|_, sel| sel.set_symbol_part(id, path))?; + Ok(json!({ "id": id.0, "path": shown })) +} + /// The `path` param: child indices from the art's root, naming an object of `art`. fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { let path: Option> = @@ -73,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { Ok(path) } +/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n` +/// does not draw that paint of its own; `None` when it does. Paths (not guides), +/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain +/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or +/// stroke has a Convert to Shape or Outline Object effect. +fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { + Some(match &n.kind { + NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, + NodeKind::Image(_) => { + let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; + if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) { + return None; + } + bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it")) + } + NodeKind::SymbolInstance { .. } if key == "fill" => return None, + NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"), + NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")), + NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")), + NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")), + NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")), + NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")), + NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")), + }) +} + +/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw +/// ([`paint_refusal`]); null, which takes a change back, is taken for every object. +fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> { + let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) }; + for key in ["fill", "stroke"] { + if p.get(key).is_some_and(|v| !v.is_null()) + && let Some(e) = paint_refusal(n, key, cmd) + { + return Err(e); + } + } + Ok(()) +} + +/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a +/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid +/// fill only, so such a fill draws nothing. +fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { + let asked = p.get("fill").is_some_and(|v| !v.is_null()); + let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); + let draws_nothing = |f: &vectorcraft_color::Paint| match f { + vectorcraft_color::Paint::Gradient(_) => true, + vectorcraft_color::Paint::Pattern { .. } => true, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, + }; + if asked && is_instance && fill.is_some_and(draws_nothing) { + return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); + } + Ok(()) +} + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result>> { @@ -114,19 +228,12 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu } let (id, art) = dynamic_instance(s, p, C)?; let path = path_param(p, &art, C)?; + check_paint_kinds(p, &art, &path, C)?; let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; - let count = s.edit("Symbol Instance Appearance", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let mut e = n.extras_mut(); - let list = &mut e.symbol_overrides; - list.retain(|o| o.path != path); - if !new.is_empty() { - list.push(new); - } - Ok(list.len()) - })?; + solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?; + let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 69bfcb66e..60353921e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result { let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; let alt = switch_or(p, "alt", false, C)?; + // Refused while an object of a dynamic instance's art is selected: the instance stays as it is. + crate::cmd::edit::refuse_symbol_part(s)?; let st = s.doc()?; let color = match color_param(p, "color", C)? { Some(c) => crate::cmd::paint::new_color(s, p, c), diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 442b68c58..2aa759411 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Make and Expand", ["Object", "Image Trace"], None, - "{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}", + "{…as imageTrace.make, byColor?: false (each color's paths in a group of their own, as imageTrace.expand)} same as imageTrace.make, then expand → {id, paths, anchors, colors}", has_selection_or_ids, |s, p| trace_make(s, p, true) ), @@ -171,7 +171,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Image Trace"], None, - "{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}", + "{id?, ids?: [id…] (instead of id), byColor?: false (each color's paths in a group of their own, named by the swatch the color links to or as #rrggbb, gradient fills in one group named Gradients; the groups stack by their lowest path and keep their paths' order; a color whose paths overlap another color's from above and below gets a group for each part, so the art draws the same; refused for a trace made with autoGrouping and for a trace with a live Pathfinder effect)} keep only the traced paths (drop the source image) → {ids}", has_selection_or_ids, trace_expand ), diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 00bcc1c83..1924ed405 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -4,10 +4,16 @@ use vectorcraft_color::Swatch; use vectorcraft_doc::ImageObject; use super::{ - Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, + Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, Rect, Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, }; +#[cfg(test)] +thread_local! { + static BOX_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static CANDIDATE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // ---------- Image Trace ---------- fn is_trace_group(n: &Node) -> bool { @@ -16,6 +22,8 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor"; + /// The `view` param ([`TraceView`] id), if given. fn view_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; @@ -368,6 +376,156 @@ fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() } +/// The `byColor` param of an Expand (false when not given). +fn by_color_param(cmd: &str, p: &Value) -> Result { + match p.get("byColor").filter(|v| !v.is_null()) { + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "byColor must be true or false")), + None => Ok(false), + } +} + +/// Expand by color: an expanded trace's `items` (bottom-most first) in a group for each paint, a +/// stroked line counted by its stroke. A solid color's group is named by the swatch it links to (a +/// tint with its percentage) or as `#rrggbb`, and gradient fills share one group, "Gradients". The +/// groups stack in the order of the lowest item in each, and each keeps its items' order. When the +/// paths meet, an item joins its paint's last group only if no item of a later group meets it +/// (by the boxes of everything they paint, effects included, within one point); otherwise it +/// starts another group of that paint, which keeps the stacking order of those items. Items +/// farther apart can still share antialiased pixels at small view or raster-export scales, so +/// their edge pixels may change. A group among `items` (Auto Grouping) is refused. +fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result>> { + use std::collections::{BTreeMap, HashMap}; + use std::ops::Bound::{Excluded, Unbounded}; + use vectorcraft_color::tint_percent; + // Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a + // gradient, or another paint (its label). The name is apart from it: a swatch may be named as + // a color's `#rrggbb` or as "Gradients". + type PaintKey = (u8, String, u32); + struct Run { + name: String, + members: Vec>, + } + // Boxes closer than a point count as overlapping: their antialiased edges can share pixels. + let meet = |a: &Rect, b: &Rect| { + #[cfg(test)] + BOX_TESTS.with(|n| n.set(n.get() + 1)); + let b = b.inflate(1.0, 1.0); + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 + }; + // Choose a cell size from the painted extent and path count. A box over more than eight cells + // across but at most eight down (a stripe) is indexed by its rows, one the other way round by + // its columns, and one over more than eight both ways stays in a separate list, keeping the + // index bounded even when a path covers most of the document. + let items: Vec<_> = items + .into_iter() + .map(|item| { + let bounds = vectorcraft_render::painted_bounds(d, &item); + (item, bounds) + }) + .collect(); + let extent = items.iter().filter_map(|(_, b)| *b).reduce(|a, b| a.union(b)); + let cell_size = extent.map_or(1.0, |r| ((r.x1 - r.x0).abs() * (r.y1 - r.y0).abs() / items.len().max(1) as f64).sqrt().max(1.0)); + // The first and last column and row of the cells `r` covers; `None` when it isn't finite. + let span = |r: Rect| -> Option<(i64, i64, i64, i64)> { + let coord = |v: f64| (v / cell_size).floor() as i64; + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then(|| (coord(r.x0), coord(r.y0), coord(r.x1), coord(r.y1))) + }; + let count = |first: i64, last: i64| i128::from(last) - i128::from(first) + 1; + // A box over more cells than this is checked against every box. + const MAX_CELLS: i128 = 1024; + let mut groups: Vec = vec![]; + let mut latest: HashMap = HashMap::new(); + // An item can join an earlier group, so insertion order is not group-index order. Index + // boxes by group to skip older groups without scanning their entries. + let mut spatial: HashMap<(i64, i64), BTreeMap>> = HashMap::new(); + let mut rows: HashMap>> = HashMap::new(); + let mut columns: HashMap>> = HashMap::new(); + let mut large: BTreeMap> = BTreeMap::new(); + let mut all_boxes: BTreeMap> = BTreeMap::new(); + for (item, bounds) in items { + if matches!(item.kind, NodeKind::Group { .. }) { + return Err(bad(cmd, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + let paint = match item.appearance.fill_paint() { + Paint::None => item.appearance.stroke_paint(), + fill => fill, + }; + let (key, name) = match &paint { + Paint::Solid { swatch: Some(n), tint, .. } => { + let percent = tint_percent(*tint); + let name = if *tint < 1.0 { format!("{n} {percent}%") } else { n.clone() }; + ((1, n.clone(), tint.to_bits()), name) + } + Paint::Solid { color, .. } => ((0, color.to_hex(), 0), color.to_hex()), + Paint::Gradient(_) => ((2, String::new(), 0), "Gradients".to_string()), + other => ((3, other.label(), 0), other.label()), + }; + // Joining an earlier group moves the item under every later group's items. + let hidden_by_later = |at: usize| { + bounds.is_some_and(|b| { + let overlaps_later = |boxes: &BTreeMap>| { + boxes.range((Excluded(at), Unbounded)).rev().any(|(_, group_boxes)| { + group_boxes.iter().rev().any(|box_bounds| { + #[cfg(test)] + CANDIDATE_VISITS.with(|n| n.set(n.get() + 1)); + meet(box_bounds, &b) + }) + }) + }; + match span(b) { + Some((x0, y0, x1, y1)) if count(x0, x1) * count(y0, y1) <= MAX_CELLS => { + (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).filter_map(|c| spatial.get(&c)).any(overlaps_later) + || (y0..=y1).filter_map(|y| rows.get(&y)).any(overlaps_later) + || (x0..=x1).filter_map(|x| columns.get(&x)).any(overlaps_later) + || overlaps_later(&large) + } + _ => overlaps_later(&all_boxes), + } + }) + }; + let at = latest.get(&key).copied().filter(|&at| !hidden_by_later(at)); + let mut started = Run { name, members: vec![] }; + let joins = at.and_then(|at| groups.get_mut(at)); + let new = joins.is_none(); + let g = joins.unwrap_or(&mut started); + g.members.push(item); + if new { + groups.push(started); + latest.insert(key, groups.len() - 1); + } + if let Some(b) = bounds { + let index = if new { groups.len() - 1 } else { at.unwrap_or_default() }; + all_boxes.entry(index).or_default().push(b); + match span(b.inflate(1.0, 1.0)) { + Some((x0, y0, x1, y1)) if count(x0, x1) <= 8 && count(y0, y1) <= 8 => { + for place in (y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))) { + spatial.entry(place).or_default().entry(index).or_default().push(b); + } + } + Some((_, y0, _, y1)) if count(y0, y1) <= 8 => { + for y in y0..=y1 { + rows.entry(y).or_default().entry(index).or_default().push(b); + } + } + Some((x0, _, x1, _)) if count(x0, x1) <= 8 => { + for x in x0..=x1 { + columns.entry(x).or_default().entry(index).or_default().push(b); + } + } + _ => large.entry(index).or_default().push(b), + } + } + } + Ok(groups + .into_iter() + .map(|run| { + let mut g = Node::group(d.alloc_id(), run.members); + g.name = Some(run.name); + Arc::new(g) + }) + .collect()) +} + /// What a trace depends on: the object traced (an image, or an Image Trace object traced again), /// its image, and the settings it is traced with. The tracer makes the same paths of the same /// inputs, so a trace made away from the command ([`TraceRun`]) stands in for the command's own. @@ -398,6 +556,10 @@ struct Prepared { fn prepare(s: &Session, c: &str, p: &Value) -> Result { let mut params = trace_params(s, c, p)?; let view = view_param(c, p)?; + let by_color = c == "imageTrace.makeAndExpand" && by_color_param(c, p)?; + if by_color && params.auto_grouping { + return Err(bad(c, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; // The preset's name if the parameters are exactly that preset's or its enhanced version's (a // saved preset's under its saved name), else "Custom". @@ -428,6 +590,9 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { }) .ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))? }; + if by_color && s.doc()?.doc.node(target).is_some_and(vectorcraft_render::effects::has_pathfinder) { + return Err(bad(c, LIVE_PATHFINDER_BY_COLOR)); + } let blob = s.doc()?.doc.images.get(&image.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", image.key)))?; let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; @@ -533,6 +698,7 @@ impl TraceRun { pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?; + let group_by_color = expand && by_color_param(c, p)?; // A saved preset's settings join the journal entry, so a replay traces as this run did after // the preset is changed, renamed or deleted. let named = str_param(p, "preset").unwrap_or("Default"); @@ -608,6 +774,9 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Result { } pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.expand"; + let by_color = by_color_param(C, p)?; let targets = trace_targets(s, p)?; + if by_color { + // A trace made with Auto Grouping is refused even when none of its paths were grouped. + let d = &s.doc()?.doc; + let grouped = |n: &Node| n.extras().trace.as_deref().and_then(|t| t.pointer("/params/autoGrouping")).and_then(Value::as_bool) == Some(true); + if targets.iter().any(|g| d.node(*g).is_some_and(grouped)) { + return Err(bad(C, "byColor can't split Auto Grouping groups: trace without Auto Grouping")); + } + if targets.iter().any(|g| d.node(*g).is_some_and(vectorcraft_render::effects::has_pathfinder)) { + return Err(bad(C, LIVE_PATHFINDER_BY_COLOR)); + } + } s.edit("Expand Image Trace", |d, sel| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; @@ -715,6 +897,15 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } + if by_color { + let items = d.node(*g).and_then(Node::children).cloned().unwrap_or_default(); + // The paths may have been moved or reshaped since the trace was made, so any trace's + // paths are taken to overlap. + let groups = color_groups(C, d, items)?; + if let Some(ch) = d.node_mut(*g).and_then(Node::children_mut) { + *ch = groups; + } + } } sel.set(targets.iter().copied()); Ok(()) @@ -722,6 +913,160 @@ pub(super) fn trace_expand(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&targets) })) } +#[cfg(test)] +mod color_group_tests { + use super::*; + use vectorcraft_geom::{PathData, SubPath}; + + fn area(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64, color: Color) -> Arc { + let path = PathData::single(SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(Paint::solid(color)))) + } + + #[test] + fn abutting_color_groups_preserve_order_at_shared_edges() { + let mut d = Document::new(100.0, 100.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = vec![area(&mut d, 0.0, 0.0, 20.0, 20.0, a), area(&mut d, 20.0, 0.0, 40.0, 20.0, b), area(&mut d, 40.0, 0.0, 60.0, 20.0, a)]; + let original: Vec<_> = items.iter().map(|n| n.id).collect(); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec<_> = groups.iter().flat_map(|g| g.children().unwrap().iter().map(|n| n.id)).collect(); + assert_eq!(actual, original, "shared-edge paths changed stacking order"); + } + + #[test] + fn dense_color_groups_use_bounded_box_tests() { + let mut d = Document::new(200.0, 200.0); + let a = Color::rgb(1.0, 0.0, 0.0); + let b = Color::rgb(0.0, 0.0, 1.0); + let items = (0..4000) + .map(|i| { + if i % 2 == 0 { + let x = 100.0 + (i % 40) as f64 * 0.01; + area(&mut d, x, 100.0, x + 0.1, 100.1, a) + } else { + let x = if i % 4 == 1 { 0.0 } else { 198.0 }; + area(&mut d, x, x, x + 2.0, x + 2.0, b) + } + }) + .collect(); + BOX_TESTS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let tests = BOX_TESTS.with(std::cell::Cell::get); + assert_eq!(groups.iter().map(|g| g.children().map_or(0, Vec::len)).sum::(), 4000); + assert!(tests < 4000 * 32, "{tests} box tests for 4000 paths"); + } + + #[test] + fn overlapping_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 3000; + let mut d = Document::new(100.0, 100.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + let items = (0..COUNT).map(|i| area(&mut d, 10.0, 10.0, 20.0, 20.0, colors[i % 2])).collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), COUNT); + assert!(visits < COUNT * 16, "{visits} candidate visits for {COUNT} paths"); + } + + #[test] + fn separate_stripes_of_alternating_colors_visit_bounded_candidates() { + const COUNT: usize = 2000; + let mut d = Document::new(1000.0, 4000.0); + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + for tall in [false, true] { + let items = (0..COUNT) + .map(|i| { + let at = i as f64 * 2.0; + if tall { area(&mut d, at, 0.0, at + 0.5, 1000.0, colors[i % 2]) } else { area(&mut d, 0.0, at, 1000.0, at + 0.5, colors[i % 2]) } + }) + .collect(); + CANDIDATE_VISITS.with(|n| n.set(0)); + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let visits = CANDIDATE_VISITS.with(std::cell::Cell::get); + assert_eq!(groups.len(), 2, "tall {tall}"); + assert!(visits < COUNT * 32, "tall {tall}: {visits} candidate visits for {COUNT} stripes"); + } + } + + #[test] + fn spatial_color_groups_match_simple_scan() { + let colors = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]; + for layout in 0..6 { + let mut d = Document::new(200.0, 200.0); + let items: Vec<_> = (0..90) + .map(|i| { + let x = match layout { + 0 => i as f64 * 2.0, + 1 => (i % 10) as f64 * 5.0, + 2 => (i % 7) as f64 * 3.0, + 3 => (i % 13) as f64 * 9.0, + 4 => (i % 4) as f64 * 10.0, + _ => i as f64 * 1.5, + }; + let y = match layout { + 0 => 0.0, + 1 => (i / 10) as f64 * 5.0, + 2 => (i / 7) as f64 * 3.0, + 3 => (i / 13) as f64 * 9.0, + 4 => i as f64 * 1.5, + _ => (i % 4) as f64 * 10.0, + }; + // Layouts 4 and 5: wide and tall stripes, some meeting their neighbors. + let (w, h) = match layout { + 4 => (150.0, 1.0), + 5 => (1.0, 150.0), + _ => (3.0, 3.0), + }; + area(&mut d, x, y, x + w, y + h, colors[i % 3]) + }) + .collect(); + let mut expected: Vec<(String, Vec<(NodeId, Rect)>)> = vec![]; + for item in &items { + let key = item.appearance.fill_paint().color().unwrap().to_hex(); + let bounds = vectorcraft_render::painted_bounds(&d, item).unwrap(); + let previous = expected.iter().rposition(|(k, _)| *k == key); + let can_join = previous.filter(|&at| { + !expected.iter().skip(at + 1).any(|(_, boxes)| { + boxes.iter().any(|(_, m)| { + let b = bounds.inflate(1.0, 1.0); + m.x0 < b.x1 && b.x0 < m.x1 && m.y0 < b.y1 && b.y0 < m.y1 + }) + }) + }); + match can_join.and_then(|at| expected.get_mut(at)) { + Some((_, members)) => members.push((item.id, bounds)), + None => expected.push((key, vec![(item.id, bounds)])), + } + } + let actual = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let actual: Vec> = actual.iter().map(|g| g.children().unwrap().iter().map(|n| n.id).collect()).collect(); + let expected: Vec> = expected.into_iter().map(|(_, members)| members.into_iter().map(|(id, _)| id).collect()).collect(); + assert_eq!(actual, expected, "layout {layout}"); + } + } + + #[test] + fn an_abutting_gradient_stays_above_the_solid_area_it_borders() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops.clone())))); + let mut d = Document::new(100.0, 100.0); + let mut area = |x0: f64, x1: f64, paint: Paint| { + let path = + PathData::single(SubPath::polyline(&[Point::new(x0, 0.0), Point::new(x1, 0.0), Point::new(x1, 60.0), Point::new(x0, 60.0)], true)); + Arc::new(Node::path(d.alloc_id(), path, fill_only(paint))) + }; + // A gradient, a solid area beside it and a second gradient beside that, bottom to top. + let items = vec![area(0.0, 50.0, ramp()), area(50.0, 80.0, Paint::solid(Color::rgb(0.2, 0.6, 0.2))), area(80.0, 100.0, ramp())]; + let groups = color_groups("imageTrace.makeAndExpand", &mut d, items).unwrap(); + let names: Vec<_> = groups.iter().map(|g| g.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Gradients", "#339933", "Gradients"]); + } +} + #[cfg(test)] mod trace_limit_tests { use super::{MAX_TRACE_ANCHORS, trace_warnings}; diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index d70a81700..221521efb 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { None, "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", has_selection, - |s, p| recolor(s, p, "Invert Colors", &invert) + |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), cmd!( "edit.colors.toCMYK", @@ -41,7 +41,7 @@ pub fn specs() -> Vec { None, "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, - |s, p| recolor(s, p, "Convert to CMYK", &to_cmyk) + |s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk) ), cmd!( "edit.colors.toGrayscale", @@ -50,7 +50,7 @@ pub fn specs() -> Vec { None, "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, - |s, p| recolor(s, p, "Convert to Grayscale", &to_gray) + |s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray) ), cmd!( "edit.colors.toRGB", @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, |s, p| { - recolor(s, p, "Convert to RGB", &|c| { + recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| { let [r, g, b] = c.to_rgb(); Color::rgb(r, g, b) }) @@ -331,26 +331,27 @@ impl ColorVisitor for Visit<'_, '_> { } } -pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { - run_pass(s, p, label, Recolor::new(Scope::of(p), f)) +pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { + run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f)) } /// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch /// link and tint. -pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { - run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links)) +pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { + run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links)) } -/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. -fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result { - let ids = match ids_param(p, "ids") { +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`; +/// errors name the command `cmd`. +fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result { + let ids = match ids_param(p, "ids", cmd)? { Some(v) => v, None => selected_roots(s)?, }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let n = s.edit(label, |d, _| { let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; Ok(n) })?; Ok(json!({ "changed": n })) @@ -358,7 +359,7 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; - recolor(s, p, "Saturate", &move |c| { + recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { let [h, sat, v] = c.to_hsb(); let to = (sat * (1.0 + i)).clamp(0.0, 1.0); // A color whose saturation stays the same is returned as it is, so it keeps its swatch link. @@ -416,12 +417,12 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result { (*tint, *c) = (t, base.tinted(t)); true }; - return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); + return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); } other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))), }; let convert = bool_or(p, "convert", false); - recolor(s, p, "Adjust Colors", &move |c| { + recolor(s, p, C, "Adjust Colors", &move |c| { let out = adjust(c); if convert { out } else { keep_model(c, out) } }) diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 7bdcfad58..d9b5784b6 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -287,7 +287,8 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { /// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Grays { - /// Through the CMYK profile like any colour: four-colour greys and a rich black. + /// Through the CMYK profile like any colour: light grays in cyan, magenta and yellow only, darker + /// grays in four inks, and a rich black. #[default] Profile, /// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs new file mode 100644 index 000000000..2b356ab85 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts.rs @@ -0,0 +1,309 @@ +//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), +//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a +//! font of its own. Type names a composite font as its family, and the font menus list them first. +//! The font database holds the active document's composite fonts ([`sync`]); a file exported here +//! carries them to another document. +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_text::FontDb; + +use super::fileio::{read_file_head, write_file}; +use super::textstyles::each_text_mut; +use super::typecmd::refresh_bounds; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param}; + +/// The extension of the files Export writes and Import reads. +pub const EXTS: &[&str] = &["vccomposite"]; + +/// The largest file Import reads, in bytes. +const MAX_FILE_BYTES: usize = 4 << 20; + +/// The keys a part may give. +const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "type.compositeFonts.list", + "Composite Fonts", + [], + None, + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + has_doc, + list + ), + cmd!( + "type.compositeFonts.set", + "Composite Fonts…", + ["Type"], + None, + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + has_doc, + set + ), + cmd!( + query "type.compositeFonts.export", + "Export Composite Fonts", + [], + None, + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + has_doc, + export + ), + cmd!( + "type.compositeFonts.import", + "Import Composite Fonts", + [], + None, + "{path: .vccomposite file | data: its text | dataBase64: its bytes (at most 4 MB), apply?: bool = true} read the composite fonts a file holds (type.compositeFonts.export writes them); with apply they join the active document's, a font of the same name (any case) replaced, as one undo step, none when nothing changes; apply false only reads them → {fonts, imported: [name]}", + has_doc, + import + ), + ] +} + +/// Register the active document's composite fonts in the font database, and remove the ones the +/// other open documents (or an earlier active document) defined. Run after each command and when +/// another document takes the front, so an undo, a revert, an opened or closed file or a document +/// switch draws type in the active document's own composite fonts, and type naming one it does +/// not define shows as missing. Whether a composite font was added, changed or removed. +pub(crate) fn sync(s: &mut Session) -> bool { + let db = FontDb::global(); + let mut names = Vec::new(); + let mut changed = false; + for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { + changed |= db.set_composite_font(&f); + names.push(f.name.to_lowercase()); + } + let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); + for gone in stale { + changed |= db.remove_composite_font(&gone); + } + s.composite_names = names; + changed +} + +/// Leave out a file's unusable composite fonts before registering and measuring its type. +pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { + let db = FontDb::global(); + if let Some(kept) = CompositeFont::usable(&doc.composite_fonts) { + *doc.composite_fonts = kept; + } + doc.composite_fonts.retain(|f| !db.has_installed_family(&f.name)); +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "fonts": *s.doc()?.doc.composite_fonts })) +} + +/// Composite font `v` from command parameters or a file: its name trimmed, each part left out a +/// copy of the Kanji part, and keys or values a part can't take refused. +fn parse_font(v: &Value, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; + if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let parts = o.get("parts").and_then(Value::as_object).ok_or_else(|| bad(c, format!("`{name}`: parts must be an object")))?; + if let Some(k) = parts.keys().find(|k| !CharClass::ALL.iter().any(|c| c.key() == k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals"))); + } + let part = |class: CharClass| -> Result> { + let Some(p) = parts.get(class.key()) else { return Ok(None) }; + let po = p.as_object().ok_or_else(|| bad(c, format!("`{name}`: {} must be an object", class.key())))?; + if let Some(k) = po.keys().find(|k| !PART_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown key `{k}` in {}", class.key()))); + } + serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) + }; + let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + for class in CharClass::ALL.into_iter().skip(1) { + if let Some(p) = part(class)? { + *f.parts.get_mut(class) = p; + } + } + f.check().map_err(|e| bad(c, format!("`{}`: {e}", f.name)))?; + Ok(f) +} + +/// Error unless `fonts` can stand together in a document: names unique in any case, none naming +/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. +fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { + if fonts.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts"))); + } + let db = FontDb::global(); + let mut names = std::collections::HashSet::new(); + for f in fonts { + if !names.insert(f.name.to_lowercase()) { + return Err(bad(c, format!("two composite fonts are named `{}`", f.name))); + } + if db.has_installed_family(&f.name) { + return Err(bad(c, format!("`{}` is the name of an installed font", f.name))); + } + } + for f in fonts { + for class in CharClass::ALL { + let font = &f.parts.get(class).font; + if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + } + } + } + Ok(()) +} + +/// Make `after` the active document's composite fonts, as one undo step named `label` (none when +/// they are what it has). The font database forgets the ones taken out, and type set in a font +/// that changed is measured again. +fn replace(s: &mut Session, label: &str, after: Vec) -> Result<()> { + let before = s.doc()?.doc.composite_fonts.to_vec(); + if before == after { + return Ok(()); + } + let changed: Vec = + before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + let db = FontDb::global(); + for f in &before { + if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { + db.remove_composite_font(&f.name); + } + } + for f in &after { + db.set_composite_font(f); + } + let r = s.edit(label, |d, _| { + *d.composite_fonts = after.clone(); + each_text_mut(d, &mut |t| { + if t.runs.iter().any(|r| changed.contains(&r.style.font_family.to_lowercase())) { + refresh_bounds(t); + } + Ok(()) + }) + }); + sync(s); + r +} + +fn set(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.set"; + let list = p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts: [{name, parts}…]"))?; + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + replace(s, "Composite Fonts", fonts.clone())?; + Ok(json!({ "fonts": fonts })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.export"; + let path = text_param(p, "path", C)?; + if path.is_some_and(|p| p.trim().is_empty()) { + return Err(bad(C, "path is empty")); + } + let all = match p.get("fonts") { + None | Some(Value::Null) => s.doc()?.doc.composite_fonts.to_vec(), + Some(Value::Array(list)) => { + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + fonts + } + Some(_) => return Err(bad(C, "fonts must be a list of composite fonts")), + }; + let fonts = match p.get("names") { + None | Some(Value::Null) => all, + Some(Value::Array(names)) => { + let mut out = Vec::with_capacity(names.len()); + for n in names { + let n = n.as_str().ok_or_else(|| bad(C, "names must be strings"))?; + let f = all + .iter() + .find(|f| CompositeFont::same_name(&f.name, n.trim())) + .ok_or_else(|| bad(C, format!("no composite font named `{n}`")))?; + if !out.contains(f) { + out.push(f.clone()); + } + } + out + } + Some(_) => return Err(bad(C, "names must be a list of names")), + }; + if fonts.is_empty() { + return Err(bad(C, "no composite fonts to export")); + } + let mut file = Map::new(); + file.insert("compositeFonts".into(), json!(fonts)); + let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + match path { + Some(path) => { + write_file(path, text.as_bytes())?; + Ok(json!({ "path": path, "count": fonts.len() })) + } + None => Ok(json!({ "data": text, "count": fonts.len() })), + } +} + +/// The composite fonts in the bytes of an exported file. +fn read_fonts(bytes: &[u8], c: &str) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| bad(c, format!("not a composite fonts file: {e}")))?; + let list = v.get("compositeFonts").and_then(Value::as_array).ok_or_else(|| bad(c, "not a composite fonts file: no compositeFonts list"))?; + if list.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("the file holds more than {COMPOSITE_FONTS_MAX} composite fonts"))); + } + list.iter().map(|f| parse_font(f, c)).collect() +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.import"; + let apply = switch_or(p, "apply", true, C)?; + let given = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let too_large = || bad(C, "the file is larger than 4 MB"); + let bytes = match given { + (Some(path), None, None) if !path.trim().is_empty() => read_file_head(path, MAX_FILE_BYTES + 1)?, + (None, Some(text), None) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => { + if b64.len() > MAX_FILE_BYTES / 3 * 4 + 4 { + return Err(too_large()); + } + vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "dataBase64 is not base64"))? + } + _ => return Err(bad(C, "give one of path, data or dataBase64")), + }; + if bytes.len() > MAX_FILE_BYTES { + return Err(too_large()); + } + let fonts = read_fonts(&bytes, C)?; + if fonts.is_empty() { + return Err(bad(C, "the file holds no composite fonts")); + } + let mut names = std::collections::HashSet::new(); + if let Some(f) = fonts.iter().find(|f| !names.insert(f.name.to_lowercase())) { + return Err(bad(C, format!("the file holds two composite fonts named `{}`", f.name))); + } + if apply { + let mut after = s.doc()?.doc.composite_fonts.to_vec(); + for f in &fonts { + match after.iter_mut().find(|a| CompositeFont::same_name(&a.name, &f.name)) { + Some(a) => *a = f.clone(), + None => after.push(f.clone()), + } + } + check_set(&after, C)?; + replace(s, "Import Composite Fonts", after)?; + } else { + check_set(&fonts, C)?; + } + let imported: Vec<&str> = fonts.iter().map(|f| f.name.as_str()).collect(); + Ok(json!({ "fonts": fonts, "imported": imported })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs new file mode 100644 index 000000000..670a85ee2 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -0,0 +1,502 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A composite font named `name`: Source Sans 3 for the base, JetBrains Mono at half size for +/// Roman, Inter for numerals. +fn font(name: &str) -> Value { + json!({"name": name, "parts": { + "kanji": {"font": "Source Sans 3"}, + "roman": {"font": "JetBrains Mono", "size": 50, "baseline": 10}, + "numerals": {"font": "Inter", "hScale": 80}, + }}) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn names(s: &Session) -> Vec { + s.doc().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn text(s: &Session, id: u64) -> vectorcraft_doc::TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// A scratch folder of the test's own. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-composite-fonts-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Composite Fonts' OK replaces the document's composite fonts as one undo step (none when nothing +/// changes); parts left out copy the Kanji part; they are saved with the document and undone with +/// it. +#[test] +fn composite_fonts_are_set_as_one_undo_step_and_saved() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font(" CF Saved A "), font("CF Saved B")]})).unwrap(); + assert_eq!(r["fonts"][0]["name"], "CF Saved A"); + assert_eq!(names(&s), ["CF Saved A", "CF Saved B"]); + assert_eq!(steps(&s), before + 1); + let f = &s.doc().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.kana.font.as_str(), f.parts.roman.size, f.parts.numerals.h_scale), ("Source Sans 3", 50.0, 80.0)); + // The same fonts again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Saved A"), font("CF Saved B")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let listed = s.execute("type.compositeFonts.list", &json!({})).unwrap(); + assert_eq!(listed["fonts"].as_array().map(Vec::len), Some(2)); + // They travel with the file. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(names(&s).is_empty()); +} + +/// Bad fonts are refused with an error and no undo step: a name that is empty, shared (in any +/// case) or an installed font's; a part set in a composite font; an unknown class or key; a value +/// out of range, of the wrong type, or on a class that can't take it; no Kanji part. +#[test] +fn bad_composite_fonts_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Refused Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font("CF Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + for bad in [ + json!({}), + json!({"fonts": "CF"}), + json!({"fonts": [font(" ")]}), + json!({"fonts": [font("CF Twice"), font("cf twice")]}), + json!({"fonts": [font("Source Sans 3")]}), + json!({"fonts": [font("Tab\tName")]}), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused Base")), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused")), + with(&|v| v["parts"]["hangul"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["Roman"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["roman"]["width"] = json!(3)), + with(&|v| v["parts"]["roman"]["size"] = json!(5)), + with(&|v| v["parts"]["roman"]["size"] = json!("50")), + with(&|v| v["parts"]["roman"]["baseline"] = json!(-120)), + with(&|v| v["parts"]["kanji"]["hScale"] = json!(90)), + with(&|v| v["parts"]["punctuation"] = json!({"font": "Inter", "vScale": 90})), + with(&|v| v["parts"]["roman"]["scaleAtCenter"] = json!(true)), + with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "scaleAtCenter": "yes"})), + with(&|v| v["parts"]["roman"]["font"] = json!("")), + with(&|v| v["extra"] = json!(1)), + with(&|v| { + v["parts"].as_object_mut().unwrap().remove("kanji"); + }), + ] { + assert!(s.execute("type.compositeFonts.set", &bad).is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Refused Base"]); + assert_eq!(steps(&s), before); + } + // Kana scaled at the center is allowed. + let ok = with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true})); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(names(&s), ["CF Refused"]); +} + +/// Type set in a composite font names it and is not missing; each class is drawn in its part's +/// font; changing the font measures the type again, and taking it out leaves the type naming it, +/// shown as missing. +#[test] +fn type_set_in_a_composite_font_follows_it() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Type")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Type"})).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + let t = text(&s, id); + assert_eq!(t.runs[0].style.font_family, "CF Type"); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &t); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("JetBrains Mono"), face("JetBrains Mono"), face("Inter"), face("Inter")]); + let missing = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().filter(|f| f["missing"] == true).count(); + assert_eq!(missing(&mut s), 0); + // Roman at full size makes the type wider, measured again in the same step. + let wide = t.cached_bounds.unwrap().width(); + let mut full = font("CF Type"); + full["parts"]["roman"]["size"] = json!(100); + let before = steps(&s); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert_eq!(steps(&s), before + 1); + assert!(text(&s, id).cached_bounds.unwrap().width() > wide + 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); + assert_eq!(db.composite_font("CF Type").unwrap().parts.roman.size, 50.0); + // Taken out, the type keeps the name, now missing; undone, the font is back. + s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap(); + assert!(db.composite_font("CF Type").is_none()); + assert_eq!(text(&s, id).runs[0].style.font_family, "CF Type"); + assert_eq!(missing(&mut s), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Type").is_some()); + assert_eq!(missing(&mut s), 0); +} + +/// Type in a symbol's art is measured again too when the composite font it names changes. +#[test] +fn type_in_symbol_art_follows_its_composite_font() { + fn width(s: &Session) -> f64 { + fn find(n: &vectorcraft_doc::Node) -> Option { + if let NodeKind::Text(t) = &n.kind { + return t.cached_bounds.map(|b| b.width()); + } + n.children().into_iter().flatten().find_map(|c| find(c)) + } + s.doc().unwrap().doc.symbols.iter().find_map(|sym| find(&sym.art)).unwrap() + } + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Symbol")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Symbol"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Mark", "ids": [id]})).unwrap(); + let narrow = width(&s); + let mut full = font("CF Symbol"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert!(width(&s) > narrow + 1.0); +} + +/// The font database forgets a composite font once no open document defines it: after the step +/// that added it is undone, and after its document closes. +#[test] +fn composite_fonts_no_document_defines_are_forgotten() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Undo")]})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Close")]})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Close").is_none()); + assert!(db.composite_font("CF Forgotten Undo").is_some()); +} + +/// SVG and legacy `.ai` write type in a composite font as its outlines, since a reader can't find +/// the font by its name. +#[test] +fn exports_outline_type_in_a_composite_font() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Export")]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Export"})).unwrap(); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(!svg.contains("CF Export") && !svg.contains(">(); + let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default()); + assert!(!svg.contains("CF Standalone") && !svg.contains(" = missing["fonts"].as_array().unwrap().iter().filter_map(|f| f["family"].as_str()).collect(); + assert_eq!(names, ["No Such Part Font"], "{missing}"); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Inventory").cloned().unwrap(); + assert_eq!((&row["missing"], &row["status"]), (&json!(true), &json!("missing")), "{row}"); + assert_eq!(row["resolved"]["family"], vectorcraft_text::FALLBACK_FAMILY, "{row}"); + // A run that starts with a combining mark after a run ending in kana: the mark is Roman. + let boundary = json!({"name": "CF Boundary", "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "No Such Part Font"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [boundary]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 170, "text": "\u{304B}\u{301}", "size": 20, "font": "CF Boundary"})).unwrap()["id"] + .as_u64() + .unwrap(); + let row = + |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["family"] == "CF Boundary").cloned(); + assert_eq!(row(&mut s).unwrap()["status"], "exact"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "size": 30})).unwrap(); + assert_eq!(text(&s, id).runs.len(), 2); + assert_eq!(row(&mut s).unwrap()["status"], "missing"); + // A symbol the Kanji part's font draws but the Symbols part's font lacks. + let db = vectorcraft_text::FontDb::global(); + let (mono, sans) = (db.face("JetBrains Mono", "Regular").unwrap(), db.face("Source Sans 3", "Regular").unwrap()); + let c = ('\u{2190}'..='\u{2BFF}').find(|c| mono.covers(*c) && !sans.covers(*c)).expect("a symbol only JetBrains Mono draws"); + let glyphs = json!({"name": "CF Glyphs", "parts": {"kanji": {"font": "JetBrains Mono"}, "symbols": {"font": "Source Sans 3"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [glyphs]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 130, "text": c.to_string(), "size": 20, "font": "CF Glyphs"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Glyphs").cloned().unwrap(); + assert_eq!(row["missingGlyphs"], 1, "{row}"); +} + +/// The Glyphs panel's alternates of a character in a composite font come from the font of the +/// part its grapheme cluster falls in. +#[test] +fn glyph_alternates_of_composite_type_come_from_the_characters_part() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Glyph Part")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 2})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 0})).unwrap(); + assert_eq!(r["font"], "JetBrains Mono", "{r}"); + // A combining mark is drawn in the part of the character it follows. + let id = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "1\u{301}", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 1})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); +} + +/// With two documents defining one name differently, the type is drawn in the front document's. +#[test] +fn the_front_document_defines_a_shared_name() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Front")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut other = font("CF Front"); + other["parts"]["roman"]["size"] = json!(75); + s.execute("type.compositeFonts.set", &json!({"fonts": [other]})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 75.0); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 50.0); +} + +/// A composite font only a document in the back defines is not available in the front one, where +/// type naming it shows as missing; a click on the back document's tab brings it back. +#[test] +fn a_font_only_a_back_document_defines_is_missing_in_front() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Only")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(db.composite_font("CF Back Only").is_none()); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Back Only"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + assert!(fonts.as_array().unwrap().iter().any(|f| f["missing"] == true), "{fonts}"); + assert!(s.set_active(0)); + assert!(db.composite_font("CF Back Only").is_some()); +} + +/// A newly opened document measures a missing font without using the previous tab's composite +/// font of the same name. +#[test] +fn opened_document_measures_missing_composite_font_as_missing() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Previous Tab")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Previous Tab"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.clear(); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(s.set_active(0)); + assert!(s.set_active(1)); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A loaded composite font cannot replace an installed family used by existing type. +#[test] +fn loaded_composite_font_cannot_take_an_installed_family_name() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.push(vectorcraft_doc::CompositeFont::new("Inter", "Source Sans 3", "Regular")); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(names(&s).is_empty()); + assert!(vectorcraft_text::FontDb::global().composite_font("Inter").is_none()); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file's composite fonts that Composite Fonts… would refuse (a value out of range, a name used +/// twice) are left out when the file opens. +#[test] +fn unusable_composite_fonts_in_a_file_are_left_out() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let mut wild = vectorcraft_doc::CompositeFont::new("CF Wild", "Source Sans 3", "Regular"); + wild.parts.roman.size = 5000.0; + let good = vectorcraft_doc::CompositeFont::new("CF Kept", "Source Sans 3", "Regular"); + let twice = vectorcraft_doc::CompositeFont::new("cf kept", "Inter", "Regular"); + *doc.composite_fonts = vec![wild, good, twice]; + s.add_document(doc, None); + assert_eq!(names(&s), ["CF Kept"]); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert!(db.composite_font("CF Wild").is_none()); + assert_eq!(db.composite_font("CF Kept").unwrap().parts.roman.font, "Source Sans 3"); +} + +/// Export writes the document's composite fonts (all, or those named) to a file; Import reads it: +/// with apply false it only returns them, otherwise they join the document's, replacing a font of +/// the same name, as one undo step. +#[test] +fn composite_fonts_export_and_import() { + let dir = folder("io"); + let path = dir.join("Mixed fonts (日本).vccomposite"); + let path = path.to_str().unwrap(); + let mut s = session(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path})).is_err(), "nothing to export"); + let mut slash = font("CF IO a/b: \"quoted\" 日本"); + slash["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true}); + s.execute("type.compositeFonts.set", &json!({"fonts": [slash, font("CF IO Other")]})).unwrap(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path, "names": ["CF IO Nope"]})).is_err()); + let r = s + .execute("type.compositeFonts.export", &json!({"path": path, "names": ["cf io a/b: \"quoted\" 日本", "CF IO a/b: \"quoted\" 日本"]})) + .unwrap(); + assert_eq!(r["count"], 1); + let saved = s.doc().unwrap().doc.composite_fonts[0].clone(); + + let mut t = session(); + let mut mine = font("CF IO a/b: \"quoted\" 日本"); + mine["parts"]["roman"]["size"] = json!(90); + t.execute("type.compositeFonts.set", &json!({"fonts": [mine, font("CF IO Kept")]})).unwrap(); + let before = steps(&t); + let read = t.execute("type.compositeFonts.import", &json!({"path": path, "apply": false})).unwrap(); + assert_eq!(read["fonts"].as_array().map(Vec::len), Some(1)); + assert_eq!(steps(&t), before); + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + assert_eq!(names(&t), ["CF IO a/b: \"quoted\" 日本", "CF IO Kept"]); + assert_eq!(t.doc().unwrap().doc.composite_fonts[0], saved); + // The same file again changes nothing. + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + + // Files that aren't composite fonts, or are too large, are refused. + for (file, bytes) in [ + ("bad.vccomposite", b"not json".to_vec()), + ("empty.vccomposite", br#"{"compositeFonts": []}"#.to_vec()), + ("twice.vccomposite", format!(r#"{{"compositeFonts": [{}, {}]}}"#, font("CF Dup"), font("cf dup")).into_bytes()), + ("large.vccomposite", vec![b' '; (4 << 20) + 1]), + ] { + let p = dir.join(file); + std::fs::write(&p, bytes).unwrap(); + assert!(t.execute("type.compositeFonts.import", &json!({"path": p.to_str().unwrap()})).is_err(), "{file}"); + assert_eq!(steps(&t), before + 1); + } + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "apply": "no"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": ""})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "data": "{}"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"dataBase64": "%%%"})).is_err()); + + // Without a path, Export gives the file's text, which Import takes as data. + let data = s.execute("type.compositeFonts.export", &json!({"names": ["CF IO Other"]})).unwrap(); + assert_eq!(data["count"], 1); + let mut u = session(); + let r = u.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(r["imported"], json!(["CF IO Other"])); + assert_eq!(names(&u), ["CF IO Other"]); + // Fonts given are written in place of the document's; bad ones are refused. + let given = s.execute("type.compositeFonts.export", &json!({"fonts": [font("CF IO Given")]})).unwrap(); + assert!(given["data"].as_str().unwrap().contains("CF IO Given")); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": [font("")]})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": []})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6860df149..407b20a1a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -743,7 +743,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let mut made = super::typecmd::with_overflow(s, id.as_slice(), made); if p.get("font").is_some() || p.get("style").is_some() { let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(), + Some(NodeKind::Text(t)) => t.runs.iter().flat_map(super::fonts::run_fonts).collect(), _ => Default::default(), }; if let Some(w) = super::fonts::set_font_warning(runs) { diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 9e0841de6..bcaa59426 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; -use super::edit::{duplicate_in, outermost, selected_roots}; +use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Move in Perspective", [], None, - "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it", + "{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction, and moves every object on that plane, at any depth, by the same plane offset; Transform Again repeats it", has_doc, persp_move ), @@ -730,8 +730,13 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { return Err(bad(C, "pins must be finite")); } - let ids = - if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + let selected = || { + refuse_symbol_part(s)?; + Ok(s.doc()?.selection.objects.clone()) + }; + let ids = if rest { puppet_ids(p, selected)? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) }; let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let angles: Vec> = match p.get("angles") { None | Some(Value::Null) => vec![None; pins.len()], diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index 786d0d956..a561e5099 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -285,7 +285,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let constrain = switch_or(p, "constrain", false, C)?; // `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false. check_switches(p, &["snap"], C)?; - // Snap to Grid: a correction for the objects on the first one's plane and depth. + // Shift: the constrained end point, read on the first object's plane and depth. let snap_to = if constrain && !perp { let doc = &s.doc()?.doc; let g = grid_of(doc); @@ -300,11 +300,20 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { } else { to }; - let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) }; - let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| { + // With Snap to Grid, every object on the first one's plane, at any depth, moves by the first + // one's plane-space offset and its correction, so the objects keep their spacing on the plane. + let fix = if perp { + None + } else { + let doc = &s.doc()?.doc; + crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to)) + .and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction))) + }; + let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| { + let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1); let (direction, dv) = if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) }; - dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| { + dv + fix.filter(|f| f.0 == pl).map_or(vectorcraft_geom::Vec2::ZERO, |f| { if !constrain { return f.2; } @@ -325,7 +334,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) } else { - let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); + let dv = snapped(pl, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) } }; @@ -336,7 +345,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { if perp { g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9) } else { - g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) + g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) } }; let first_still = if copy { None } else { unmoved(s, &ids, still)? }; @@ -350,9 +359,7 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result { let again = match perp { _ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})), true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), - false => { - g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) - } + false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, dv))), "copy": copy})), }; if let Some(a) = again { record_again(s, a)?; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 260b981d2..972fd8d1a 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", has_doc, info ), @@ -130,13 +130,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects and the rows of its Repeat objects. +/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the +/// rows of its objects filled or stroked with a pattern. fn sections( d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], repeats: &[(String, String)], + pattern_objects: &[(String, String)], category: Option<&str>, ) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { @@ -180,7 +182,7 @@ fn sections( "graphicStyles" => names("graphicStyleNames"), "brushes" => names("brushNames"), "spotColors" => names("spotColors"), - "patterns" => names("patternNames"), + "patterns" => pattern_objects.to_vec(), "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), @@ -252,12 +254,67 @@ pub fn report(s: &mut Session, selection_only: bool) -> Result { Ok(v["text"].as_str().unwrap_or_default().to_string()) } -fn paint_kind(p: &Paint) -> Option<&'static str> { - match p { - Paint::Gradient(_) => Some("gradients"), - Paint::Pattern { .. } => Some("patterns"), - _ => None, +/// The type object `n` holds and the runs its layout draws, when it uses a pattern: the runs with a +/// glyph outline (list markers and ruby included) or an underline or strikethrough bar. Spaces and +/// line breaks draw no outline. A type object's characters carry their own fill and stroke, and the +/// type object's own fills and strokes paint on its characters, so only those runs paint. The type +/// is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { + let NodeKind::Text(t) = &n.kind else { return None }; + let is_pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let own = n.appearance.items.iter().any(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => is_pattern(&f.paint), + vectorcraft_doc::AppearanceItem::Stroke(s) => is_pattern(&s.paint), + }); + if !own && !t.runs.iter().any(|r| is_pattern(&r.style.fill) || is_pattern(&r.style.stroke)) { + return None; } + let db = vectorcraft_text::FontDb::global(); + let resolved = d.inline_resolved(t); + let lay = vectorcraft_text::layout(db, &resolved); + let mut painted: BTreeSet = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); + painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); + Some((t, painted)) +} + +/// The patterns `n`'s own fills and strokes paint, each once. A hidden fill or stroke and a stroke +/// with no width paint nothing, and neither does any fill or stroke of a guide (drawn in the guide +/// color), of a compound path's member (`members`: the compound path's own fills and strokes paint +/// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint +/// only where an effect gives them a shape. +fn own_patterns<'a>( + n: &'a Node, + members: &HashSet<*const Node>, + painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, +) -> Vec<&'a str> { + if matches!(n.kind, NodeKind::Path { guide: true, .. }) + || members.contains(&std::ptr::from_ref(n)) + || painted_runs.is_some_and(|(_, r)| r.is_empty()) + { + return vec![]; + } + // The patterns an image's shaped fills and strokes paint (their effects can transform the + // pattern, so they are matched by name). + let shaped: Option> = matches!(n.kind, NodeKind::Image(_)).then(|| { + vectorcraft_render::effects::image_paint(n) + .into_iter() + .filter_map(|(_, _, item)| match item.paint() { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }) + .collect() + }); + let mut out: Vec<&str> = vec![]; + for item in &n.appearance.items { + let Paint::Pattern { pattern, .. } = item.paint() else { continue }; + let paints = item.visible() + && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) + && shaped.as_ref().is_none_or(|s| s.contains(pattern)); + if paints && !out.contains(&pattern.as_str()) { + out.push(pattern); + } + } + out } fn info(s: &mut Session, p: &Value) -> Result { @@ -293,9 +350,19 @@ fn info(s: &mut Session, p: &Value) -> Result { // `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order. let mut repeats: Vec = vec![]; let mut repeat_rows: Vec<(String, String)> = vec![]; + // `pattern_objects` and `pattern_rows` hold the `patternObjects` entries and the rows of the objects filled or stroked with a pattern, in walk order. + let mut pattern_objects: Vec = vec![]; + let mut pattern_rows: Vec<(String, String)> = vec![]; // The objects the walk has visited: a selection can hold a group and objects inside it, and each object counts once. // The set holds each object's address, so two objects that share an id (as a damaged file can hold) count as two. let mut seen: HashSet<*const Node> = HashSet::new(); + // The members of the document's compound paths: the compound path's own fills and strokes paint them. + let mut members: HashSet<*const Node> = HashSet::new(); + d.walk(|n| { + if let NodeKind::Compound { children, .. } = &n.kind { + members.extend(children.iter().map(|c| std::ptr::from_ref(&**c))); + } + }); for r in roots { r.walk(&mut |n| { if !seen.insert(std::ptr::from_ref(n)) { @@ -345,16 +412,18 @@ fn info(s: &mut Session, p: &Value) -> Result { } // The paint kinds this object's fills and strokes use, each once. let mut kinds = BTreeSet::new(); + // The patterns this object's fills and strokes use, each once. + let mut used_patterns: Vec<&str> = vec![]; + let painted_runs = painted_runs(d, n); + for pattern in own_patterns(n, &members, painted_runs.as_ref()) { + patterns.insert(pattern.to_string()); + used_patterns.push(pattern); + } for item in &n.appearance.items { - let paint = match item { - vectorcraft_doc::AppearanceItem::Fill(f) => &f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &s.paint, - }; - if let Some(k) = paint_kind(paint) { - kinds.insert(k); - } - if let Paint::Pattern { pattern, .. } = paint { - patterns.insert(pattern.clone()); + let paint = item.paint(); + // Objects counts an object with a pattern when Pattern Objects lists it, below. + if let Paint::Gradient(_) = paint { + kinds.insert("gradients"); } if let Paint::Solid { swatch: Some(name), .. } = paint && d.swatch(name).is_some_and(|s| s.spot) @@ -362,6 +431,28 @@ fn info(s: &mut Session, p: &Value) -> Result { spot.insert(name.clone()); } } + // The characters' own paints: an inline graphic draws no outline and does not take its + // run's paint (a list marker set in its run does), and a stroke with no width paints + // nothing. + if let Some((t, painted)) = &painted_runs { + for run in painted.iter().filter_map(|i| t.runs.get(*i)) { + let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); + for paint in std::iter::once(&run.style.fill).chain(stroke) { + if let Paint::Pattern { pattern, .. } = paint { + patterns.insert(pattern.clone()); + if !used_patterns.contains(&pattern.as_str()) { + used_patterns.push(pattern); + } + } + } + } + } + if !used_patterns.is_empty() { + let name = n.display_name(); + pattern_objects.push(json!({ "id": n.id.0, "name": name, "patterns": used_patterns })); + pattern_rows.push((name, used_patterns.join(", "))); + kinds.insert("patterns"); + } for k in kinds { *counts.entry(k).or_default() += 1; } @@ -412,10 +503,11 @@ fn info(s: &mut Session, p: &Value) -> Result { "paragraphStyles": d.para_styles.len(), "patterns": d.patterns.len(), "patternNames": patterns, + "patternObjects": pattern_objects, }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -634,6 +726,120 @@ mod tests { assert_eq!(i["sections"][0]["rows"][0], json!(["", "Grid Repeat, 500 rows × 500 columns, 125000 instances"])); } + /// Document Info › Pattern Objects lists each object filled or stroked with a pattern once, + /// with the patterns it uses, one in a group and a type object whose characters use a pattern + /// included; a pattern no art uses adds no row, and Objects counts the listed objects. With + /// Selection Only it lists the patterned objects in the selection, and the report writes the + /// same rows. + #[test] + fn info_lists_the_pattern_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let tile = square(&mut s, 0.0); + s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap(); + let spare = square(&mut s, 20.0); + s.execute("object.pattern.make", &json!({"ids": [spare], "name": "Unused", "edit": false})).unwrap(); + let (a, b, c) = (square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0)); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // A type object whose characters are filled with the pattern. + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + // A hidden pattern fill and a pattern stroke with no width paint nothing, so neither object is listed. + let (d, e) = (square(&mut s, 100.0), square(&mut s, 200.0)); + s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // A type object with no characters paints nothing either. + let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap(); + // Its own fill (a copy of its characters' pattern fill) paints on its characters, so it paints nothing. + s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap(); + // Nor does type of only spaces and a line break, or a guide made from a patterned path. + let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Dots"})).unwrap(); + let guide = square(&mut s, 300.0); + s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [guide]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + // Nor does a compound path's member's own pattern fill: the compound path's own fill paints it. + let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap(); + let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone(); + let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance); + // Nor does an image's pattern fill without an effect that gives it a shape. + let pic = square(&mut s, 360.0); + s.execute("select.set", &json!({"ids": [pic]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let image = s.doc().unwrap().selection.objects[0]; + s.edit("own paint", |d, _| { + d.node_mut(member).unwrap().appearance = paint.clone(); + d.node_mut(image).unwrap().appearance = paint; + Ok(()) + }) + .unwrap(); + // Underlined spaces draw a bar in the pattern, so that type object is listed. + let bar = s.execute("text.create", &json!({"x": 0, "y": 340, "text": " "})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [bar], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [bar]})).unwrap(); + s.execute("text.setFormat", &json!({"underline": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": bar, "name": "Bar"})).unwrap(); + // A list marker set in an inline graphic's run draws in that run's pattern, so that type object is listed. + s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 10, "height": 10})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let listed = s.execute("text.create", &json!({"x": 0, "y": 360, "text": ""})).unwrap()["id"].clone(); + s.execute("text.insertInline", &json!({"id": listed, "at": 0, "symbol": "Mark"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [listed], "list": "bullet"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [listed], "swatch": "Dots"})).unwrap(); + s.execute("layer.setProps", &json!({"id": listed, "name": "Listed"})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + i["patternObjects"], + json!([ + {"id": a, "name": "Both", "patterns": ["Dots"]}, + {"id": b, "name": "", "patterns": ["Dots"]}, + {"id": t, "name": "Type", "patterns": ["Dots"]}, + {"id": bar, "name": "Bar", "patterns": ["Dots"]}, + {"id": listed, "name": "Listed", "patterns": ["Dots"]}, + ]) + ); + assert_eq!(i["patternNames"], json!(["Dots"])); + // Objects counts the objects that Pattern Objects lists. + assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]); + s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]); + let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("patterns"), Some("Pattern Objects"))); + assert_eq!( + i["sections"][0]["rows"], + json!([["Both", "Dots"], ["", "Dots"], ["Type", "Dots"], ["Bar", "Dots"], ["Listed", "Dots"]]) + ); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Dots"]])); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "patterns"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["patternNames"].clone()), (json!([["Type", "Dots"]]), json!(["Dots"]))); + let text = s.execute("document.info", &json!({"category": "patterns", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nPATTERN OBJECTS\nBoth: Dots\n: Dots\nType: Dots\nBar: Dots\nListed: Dots\n"), "{text}"); + assert!(!text.contains("Unused"), "{text}"); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5b1b4300b..a475bc743 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec { "Document Color Mode", ["File", "Document Color Mode"], None, - "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", + "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", has_doc, super::colormgmt::convert_mode ), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dbe3d4509..bf6b673ff 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -41,6 +41,15 @@ pub fn specs() -> Vec { cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), + cmd!( + query "document.compare", + "Compare with Image", + [], + None, + "{path: an image file (PNG, JPEG, WebP, TIFF or BMP), artboard?: index=0, background?: \"#rrggbb\"=\"#ffffff\" (the render and the image are both flattened on it), threshold?: 0.08 (0–1), diffPath?: a PNG to write (each pixel's absolute R, G and B differences)} render the artboard at the image's size (the image's width in pixels over the artboard's width in points), drawn as document.export draws a PNG (linked images and placed documents from their files), and compare it with the image; an image whose height differs by more than a pixel from the artboard's height at that scale is refused (not in the browser) → {width, height, rmse: the root mean square of the R, G and B differences, 0–1 (6 decimals), over: the pixels with a channel that differs by more than threshold, total: the pixels, threshold, diffPath?, warnings?: as a PNG export's (type that doesn't fit its frame, an image that couldn't be decoded, type in a font or style that isn't available, previews drawn because a linked file can't be read)}", + has_doc, + doc_compare + ), cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), cmd!( "document.setUnits", @@ -148,7 +157,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected", has_selection_guides_or_artboard_tool, cut ), @@ -157,7 +166,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -211,7 +220,7 @@ pub fn specs() -> Vec { "Clear", ["Edit"], Some("Delete"), - "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)", + "{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected", has_selection_or_guides, clear ), @@ -385,6 +394,155 @@ fn select_found(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result { }) } +/// Render an artboard at an image's size, drawn as a PNG export draws it (linked images and placed +/// documents from their files, with the export's warnings), and measure how far the +/// two are apart: both flattened on `background`, the root mean square of the R, G and B +/// differences (0–1), and the pixels with a channel that differs by more than `threshold`. +#[cfg(not(target_arch = "wasm32"))] +fn doc_compare(s: &mut Session, p: &Value) -> Result { + use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, png}; + const C: &str = "document.compare"; + let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let diff_path = match p.get("diffPath") { + None | Some(Value::Null) => None, + Some(Value::String(v)) => Some(v.as_str()), + Some(_) => { + let key = "diffPath"; + return Err(bad(C, format!("`{key}` must be a string"))); + } + }; + let background = match p.get("background") { + None | Some(Value::Null) => [255; 3], + Some(v) => match super::fileio::background(v) { + Ok(Some(rgb)) => rgb, + _ => return Err(bad(C, format!("background {v}: a color such as \"#808080\""))), + }, + }; + let threshold = match p.get("threshold") { + None | Some(Value::Null) => 0.08, + Some(v) => v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad(C, "`threshold` must be a number from 0 to 1"))?, + }; + let doc = &s.doc()?.doc; + let count = doc.artboards.len(); + let i = opt_u64(p, C, "artboard")?.unwrap_or(0); + let region = usize::try_from(i) + .ok() + .and_then(|i| doc.artboards.get(i)) + .map(|a| a.rect) + .ok_or_else(|| bad(C, format!("no artboard {i} (0-based; the document has {count})")))?; + if let Some(d) = diff_path { + crate::file_access::check_write(d).map_err(EngineError::Other)?; + } + let image = compare_image(path)?; + let (iw, ih) = image.dimensions(); + // The image's width sets the scale; its height may differ from the artboard's at that scale by + // the pixel the rounding of a render takes. + let scale = f64::from(iw) / region.width(); + let rendered_height = region.height() * scale; + if iw == 0 || ih == 0 || !rendered_height.is_finite() || (rendered_height - f64::from(ih)).abs() > 1.0 { + let (rw, rh) = (iw, rendered_height.round()); + return Err(bad( + C, + format!("the image is {iw} × {ih} pixels and the artboard at that width is {rw} × {rh} pixels: their proportions differ"), + )); + } + vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; + vectorcraft_render::raster_size(vectorcraft_geom::Rect::new(0.0, 0.0, f64::from(iw), f64::from(ih)), 1.0).map_err(|e| bad(C, e))?; + let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + let opts = RasterExportOptions { background: Some(background), ..Default::default() }.render_options(RasterFormat::Png); + // As a PNG export: linked images and placed documents drawn from their files, not the + // previews saved for them. + let (full, linked) = super::place::document::full_documents(doc); + let failed = vectorcraft_render::decode_failures(); + let rendered = vectorcraft_render::Renderer::new().render(&full, iw, ih, view, &opts); + // A canceled render stops drawing: the picture is not whole. + vectorcraft_doc::cancel::check()?; + // As a PNG export says, in its order: type on the artboard that doesn't fit its frame, an image + // the renderer couldn't decode (missing from the render), type in a missing font or style (drawn + // in another one), and linked files drawn from their previews. + let mut warnings: Vec = super::fonts::export_overflow_warning(&full, &full.layers, Some(&[region])).into_iter().collect(); + if let Some(w) = super::rasterfx::undecoded_warning(failed) { + warnings.push(w.into()); + } + if let Some(w) = super::fonts::substitution_warning(&full) { + warnings.push(w); + } + warnings.extend(linked); + let rendered = rendered.to_straight(); + let flat = |px: &[u8; 4]| { + let a = u32::from(px[3]); + [0, 1, 2].map(|k| ((u32::from(px[k]) * a + u32::from(background[k]) * (255 - a) + 127) / 255) as u8) + }; + let row = iw as usize * 4; + let limit = threshold * 255.0; + let (mut squares, mut over) = (0.0f64, 0u64); + let mut diff = diff_path.map(|_| Vec::with_capacity(row * ih as usize)); + for (a, b) in rendered.chunks_exact(row).zip(image.as_raw().chunks_exact(row)) { + vectorcraft_doc::cancel::check()?; + for (pa, pb) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0) { + let (fa, fb) = (flat(pa), flat(pb)); + let d = [0, 1, 2].map(|k| fa[k].abs_diff(fb[k])); + squares += d.iter().map(|&x| f64::from(x) * f64::from(x)).sum::(); + if d.iter().any(|&x| f64::from(x) > limit) { + over += 1; + } + if let Some(out) = &mut diff { + out.extend_from_slice(&[d[0], d[1], d[2], 255]); + } + } + } + let total = u64::from(iw) * u64::from(ih); + let rmse = (squares / (total as f64 * 3.0)).sqrt() / 255.0; + let mut out = json!({ + "width": iw, + "height": ih, + "rmse": (rmse * 1e6).round() / 1e6, + "over": over, + "total": total, + "threshold": threshold, + }); + if let (Some(d), Some(pixels)) = (diff_path, diff) { + let bytes = png::encode(&pixels, iw, ih, &png::PngOptions { ppi: None, interlaced: false }).map_err(EngineError::Other)?; + super::fileio::write_file(d, &bytes)?; + out["diffPath"] = json!(d); + } + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } + Ok(out) +} + +/// The image `document.compare` reads from `path` (through the automation read root), as straight +/// RGBA: a PNG, JPEG, WebP, TIFF or BMP within the allocation an opened image may take. +#[cfg(not(target_arch = "wasm32"))] +fn compare_image(path: &str) -> Result { + use image::ImageDecoder as _; + const C: &str = "document.compare"; + let bytes = super::fileio::read_file(path)?; + if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(&bytes) { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| bad(C, format!("{path}: {e}")))?; + let readable = [image::ImageFormat::Png, image::ImageFormat::Jpeg, image::ImageFormat::WebP, image::ImageFormat::Tiff, image::ImageFormat::Bmp]; + if !reader.format().is_some_and(|f| readable.contains(&f)) { + return Err(bad(C, format!("{path}: not a PNG, JPEG, WebP, TIFF or BMP image"))); + } + let mut limits = image::Limits::default(); + limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC); + reader.limits(limits); + let decoder = reader.into_decoder().map_err(|e| bad(C, format!("{path}: {e}")))?; + let (w, h) = decoder.dimensions(); + if decoder.total_bytes() > vectorcraft_doc::MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > vectorcraft_doc::MAX_RASTER_ALLOC { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); + } + Ok(image::DynamicImage::from_decoder(decoder).map_err(|e| bad(C, format!("{path}: {e}")))?.to_rgba8()) +} + +#[cfg(target_arch = "wasm32")] +fn doc_compare(_: &mut Session, _: &Value) -> Result { + Err(bad("document.compare", "comparing with an image file isn't available in the browser")) +} + /// Document Setup's units (also `document.setup {units}`). fn set_units(s: &mut Session, p: &Value) -> Result { let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?; @@ -636,11 +794,14 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc)); let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone()); - if let Some(path) = st.path.as_deref() { + let base = super::links::LinkBase::of(st); + if base.doc.is_some() { // Files are looked for within the asking caller's roots, else within those of the caller that opened the source. let roots = crate::file_access::current().or_else(|| opened_by.clone()); - crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path)); + crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, base)); } + // The copy's links keep their relative paths, which name places from the same file. + let links_from = base.relative.map(str::to_string); doc.drop_edit_modes(); if doc.pattern_edit.is_some() { super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?; @@ -657,6 +818,7 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { let st = s.doc_mut()?; st.imported_from = imported_from; st.import_losses = import_losses; + st.links_from = links_from; st.opened_by = st.opened_by.take().or(opened_by); st.mark_unsaved(); Ok(json!({ "index": i, "title": st.title() })) @@ -671,16 +833,38 @@ fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate:: } /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. +/// +/// Refused with [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art +/// is selected ([`super::brushsym::selected_part`]): only the paint commands change that object, +/// and only its solid fill and stroke, so a command that gathers the selection here leaves the +/// instance as it is. Copy copies the instance whole ([`copy_cut_targets`]). pub(crate) fn selected_roots(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art is selected: +/// for the commands that read the selected objects without [`selected_roots`]. A command that +/// changes nothing in the document ([`Session::reading`]) reads the instance instead. +pub(crate) fn refuse_symbol_part(s: &Session) -> Result<()> { + if !s.reading && s.doc()?.selection.symbol_part.is_some() { Err(super::brushsym::part_only()) } else { Ok(()) } +} + +/// [`refuse_symbol_part`] as a command's `enabled` check: the command is dimmed, and refused with +/// [`super::brushsym::part_only`]'s message, while an object of a dynamic symbol instance's art is +/// selected. +pub(crate) fn no_symbol_part(s: &Session) -> std::result::Result<(), String> { + if !s.reading && s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { Err(super::brushsym::part_only().to_string()) } else { Ok(()) } +} + /// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a /// path of a compound path selected on its own (Group Selection, or in the isolated compound path) /// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the -/// rest still go to the compound path, which holds the paint. +/// rest still go to the compound path, which holds the paint. Refused while an object of a symbol +/// instance's art is selected, as [`selected_roots`] is: it moves with its instance only. pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_, _| true)) } @@ -689,8 +873,10 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { /// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the /// objects held by another selected object are dropped. A selected path of a compound path stands /// for its compound unless the compound is `root` or `members` is set (as in -/// [`selected_roots_to_transform`]). +/// [`selected_roots_to_transform`]). Refused while an object of a symbol instance's art is +/// selected, as [`selected_roots`] is. pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result> { + refuse_symbol_part(s)?; let st = s.doc()?; let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect(); Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root)) @@ -748,7 +934,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option>)>) -> i /// The direct-selection flag and resolved root nodes for Copy and Cut operations. fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let st = s.doc()?; - let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature"); + // The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes + // the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123). + let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection); + let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked; let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty()); let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))); let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| { @@ -767,6 +956,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } + // With an object of a dynamic symbol instance's art selected, the instance is copied. let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; // Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay @@ -807,6 +997,7 @@ fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } + refuse_symbol_part(s)?; // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { copy(s, p)?; @@ -880,6 +1071,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { fn clear(s: &mut Session, p: &Value) -> Result { let guides = switch_or(p, "guides", false, "edit.clear")?; let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None }; + // An object of a symbol instance's art can't be removed from the instance alone. + if explicit.is_none() && s.doc()?.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } // Direct-selected anchors or segments: remove those, and any objects selected whole with them. if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) { return super::path::delete_anchors(s, p); @@ -1111,10 +1306,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for (k, n) in clip.nodes.iter().enumerate() { let mut c = d.reid(n); imported.apply(&mut c); + let sc = vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }; if xf != Affine::IDENTITY { // A cross-document drag carries the graphic's appearance with it, // including the pattern phase, independently of transform-tool settings. - c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }); + c.transform(xf, sc); } let layer = match clip.source_layers.get(k) { Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), @@ -1150,6 +1346,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho if let NodeKind::Path { live, .. } = &mut child_node.kind { *live = None; } + if child_node.extras().pixel_aligned { + child_node.extras_mut().pixel_aligned = false; + } let insert_idx = if idx == usize::MAX { usize::MAX } else { idx.saturating_add(offset) }; new_ids.push(d.insert(par, insert_idx, child_node)?); } @@ -1162,6 +1361,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho if let NodeKind::Path { live, .. } = &mut c.kind { *live = None; } + // A path inside a compound path snaps with the compound path, whose property it takes. + if c.extras().pixel_aligned { + c.extras_mut().pixel_aligned = false; + } new_ids.push(d.insert(par, idx, c)?); } _ => { @@ -1170,14 +1373,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let offset = *placed.entry(outer_par).or_default(); *placed.entry(outer_par).or_default() += 1; let insert_idx = if beside_idx == usize::MAX { usize::MAX } else { beside_idx.saturating_add(offset) }; + super::object::keep_pixel_aligned(&mut c, sc); new_ids.push(d.insert(outer_par, insert_idx, c)?); } } } else { + super::object::keep_pixel_aligned(&mut c, sc); new_ids.push(d.insert(par, idx, c)?); } } } + for id in &new_ids { + super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default()); + } sel.set(new_ids.iter().copied()); // Copied ruler guides (#3226): on the layer new art goes to, moved as the objects are (a // paste of guides alone keeps them where they were, but onto another artboard or every @@ -1249,7 +1457,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { /// Duplicate `roots` directly above each original, transform the copies, select them /// ([`copy_above`]). pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result> { - let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?; + let out = copy_above(d, roots, |_, _, c| { + c.transform(xf, true); + super::object::keep_pixel_aligned(c, true.into()); + })?; sel.set(out.iter().copied()); Ok(out) } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 21f43e0ee..c7bee5400 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -18,6 +18,22 @@ use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or}; +/// The name of the `svg.filter` effect drawing `xml`: its `` id. An effect names the +/// document's filter of that name, so `xml` that differs from the document's filter of the same +/// name is refused. +fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result { + let name = effects::svg_filter::check(xml).map_err(|e| bad(cmd, e))?; + if let Some(held) = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name) + && held.xml != xml.trim() + { + return Err(bad( + cmd, + format!("the document has another SVG filter named {name:?}: use svgFilter.apply, or svgFilter.define with `replace` to edit it"), + )); + } + Ok(name) +} + /// The effect commands of the Effect menu and the Appearance panel. pub fn specs() -> Vec { vec![ @@ -35,7 +51,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin., menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", + "{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin., menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}] (an effect has skipped: true while Safe Mode skips it: a plug-in effect, which the object keeps and draws without), activeItem} for the selection", always, list ), @@ -71,7 +87,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -245,6 +261,11 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if texture_load_effect(id) { load_texture(&mut params, C)?; } + // An SVG filter needs its element, and is named by its id. + if id == effects::SVG_FILTER { + let xml = params.get("xml").and_then(Value::as_str).ok_or_else(|| bad(C, "give `xml`, one SVG element"))?; + params["name"] = json!(svg_filter_name(s, xml, C)?); + } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); aligned(p, C)?; @@ -307,7 +328,22 @@ fn list(s: &mut Session, p: &Value) -> Result { .collect(); let mut applied = vec![]; if s.active().is_some() { - let fx = |e: &[Effect]| serde_json::to_value(e).unwrap_or(Value::Null); + // A plug-in effect Safe Mode skips says so (`skipped`): the object keeps it. + let fx = |e: &[Effect]| { + Value::Array( + e.iter() + .map(|e| { + let mut v = serde_json::to_value(e).unwrap_or(Value::Null); + if vectorcraft_plugins::effect::skipped(&e.id) + && let Some(o) = v.as_object_mut() + { + o.insert("skipped".into(), json!(true)); + } + v + }) + .collect(), + ) + }; for id in appearance_targets(s, p)? { if let Some(n) = s.doc()?.doc.node(id) { let items: Vec = n @@ -521,6 +557,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [] })); } } + // An SVG filter is named by its id: another filter renames the effect. + if edited.iter().any(|e| e == effects::SVG_FILTER) + && let Some(xml) = params.get("xml").and_then(Value::as_str) + { + params["name"] = json!(svg_filter_name(s, xml, C)?); + } for &(id, target, index) in &targets { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); @@ -879,8 +921,40 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br changed.then_some(n) } +/// Does `n`, an object in it, or the art of a symbol placed in it hold a plug-in effect that +/// expanding it draws: a visible one, the object's own or a visible fill's or stroke's, on an +/// object that expands ([`expandable`]; in a symbol's art, `in_art`, every object does). A symbol's +/// art counts where an instance of it expands. `seen` holds the symbols already looked into. +fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::collections::HashSet) -> bool { + let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some(); + let mut symbols = vec![]; + let mut holds = false; + n.walk(&mut |c| { + if !in_art && !expandable(c) { + return; + } + holds |= c.appearance.effects.iter().any(plugin) || c.appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(plugin)); + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + symbols.push(symbol.clone()); + } + }); + holds + || symbols.into_iter().any(|name| { + seen.insert(name.clone()) && d.symbols.iter().find(|s| s.name == name).is_some_and(|s| holds_plugin_effect(d, &s.art, true, seen)) + }) +} + fn expand_appearance(s: &mut Session, p: &Value) -> Result { let roots = appearance_targets(s, p)?; + // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the + // geometry it hasn't changed. + if vectorcraft_plugins::safe_mode::active() { + let d = &s.doc()?.doc; + let mut seen = std::collections::HashSet::new(); + if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { + return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); + } + } let failed = vectorcraft_render::decode_failures(); let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index d67b89a97..de056e4ec 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -407,8 +407,9 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { let mut expanded = false; for item in &l.appearance.items { // Items that paint nothing go; a gradient that can't be expanded stays. - let art = gradient_fill(item).and_then(|(f, g)| { - let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?; + let art = gradient_fill(item).and_then(|_| { + let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?; + let (f, g) = gradient_fill(&moved)?; gradient_art(d, &fl, &path, bounds, f, g, to) }); let Some(art) = art else { @@ -470,29 +471,39 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F /// path (a plain path then), and every geometry effect of its own removed. `None` when it has no /// visible geometry effect. fn reshaped(l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; if !has_geometry(&l.appearance.effects) { return None; } let (path, _) = node_path(l)?; - let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); + let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l)); let mut m = with_path(l, g); + // Its gradients move with the outline, as the canvas paints them. + if carry.moves(&m.appearance) { + carry.apply(&mut m.appearance); + } m.appearance.effects.retain(|e| !is_geometry(&e.id)); Some(m) } /// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its -/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of -/// `path` as a plain path, and that outline's box. -fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry}; +/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they +/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along +/// with the outline. +fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> { + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry}; if !has_geometry(item.effects()) { - return Some((l.clone(), path.clone(), bounds)); + return Some((l.clone(), path.clone(), bounds, item.clone())); } // In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on. - let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item)); + let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item)); let b = outline.bounds()?; - Some((with_path(l, outline.clone()), outline, b)) + let mut one = Appearance { items: vec![item.clone()], ..Default::default() }; + if carry.moves(&one) { + carry.apply(&mut one); + } + let moved = one.items.pop().unwrap_or_else(|| item.clone()); + Some((with_path(l, outline.clone()), outline, b, moved)) } /// Path or compound path `l` with `path` as its path, a plain path. @@ -809,7 +820,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, & later -= usize::from(fill.is_some()); let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left }; let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| { - let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; + let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?; let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else { full = true; return None; diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 0c888d13c..13ad41667 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,7 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -32,7 +33,9 @@ mod export; pub(crate) mod imagemap; mod legacy_ai; mod load; +mod mesh3d; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +mod heifread; mod jp2read; mod metafile; mod naming; @@ -54,6 +57,7 @@ pub mod zip; use serde_json::{Value, json}; +pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; @@ -72,6 +76,8 @@ pub use load::{ read_for_open, texture_image, }; pub use load::{losses_summary, overwrite_losses}; +pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label}; +pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; @@ -123,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched}; pub(crate) use access::{may_read, may_write}; pub use commands::specs; pub use export_names::artboard_file_names; -pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return}; +pub(crate) use export_names::{ + artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, +}; use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ @@ -202,6 +210,8 @@ mod tests_psd; #[cfg(test)] mod tests_psdread; +#[cfg(test)] +mod tests_heifread; #[cfg(test)] mod tests_jp2read; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index e11b6adcd..ded4dcd5d 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -95,11 +95,93 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { }; match source { "folder" => batch_folder(s, C, p, override_open, &run), - "datasets" => batch_datasets(s, C, &run), + "datasets" => { + let one_file = batch_overwrites_one_file(s, p); + let mut r = batch_datasets(s, C, &run)?; + if one_file { + r["warning"] = json!(ONE_FILE_WARNING); + } + Ok(r) + } _ => Err(bad(C, "`source` must be `folder` or `datasets`")), } } +/// The `warning` of a Data Sets batch for which [`batch_overwrites_one_file`] holds. +#[cfg(not(target_arch = "wasm32"))] +const ONE_FILE_WARNING: &str = "each data set saves over the same file, which keeps only the last data set"; + +/// Whether an `action.batch` with params `p` plays on data sets of the open document in `s` and +/// writes each data set to the same file. Save and Close saves the open document over its own file +/// after every data set, unless a Save step that Override Save points into `destinationFolder` +/// first makes each data set's file the document's own. A step that the batch does not point into `destinationFolder` writes the +/// same file every time when it writes one: a Save, Save As, Save a Copy, Save as Template, Export, +/// Export Selection, PDF, EPS, DXF, Save for Web or Save for Office Documents step that gives a +/// `path`; a Save step without a `path` when the document has a file of its own (saved, and not +/// converted from an older version or restored by Data Recovery) and the step gives no `format` or +/// the document's own; an Export for Screens step whose `folder`, given or from its +/// `exportPreset`, names a folder, whatever the overrides. +/// Other steps return their data instead of writing a file. +pub fn batch_overwrites_one_file(s: &Session, p: &Value) -> bool { + if p.get("source").and_then(Value::as_str) != Some("datasets") { + return false; + } + let folder = p.get("destinationFolder").and_then(Value::as_str).is_some(); + let redirected = |key: &str| folder && p.get(key).and_then(Value::as_bool).unwrap_or(true); + let (save, export) = (redirected("overrideSave"), redirected("overrideExport")); + let steps = p.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice); + if p.get("destination").and_then(Value::as_str) == Some("saveAndClose") && !saves_move_to_each_file(steps, save) { + return true; + } + // The format of the open document's own file, which a Save step without a `path` writes. + let own_format = s.active().filter(|st| st.path.is_some() && !st.converted && !st.recovered).map(|st| st.format); + let names = |v: Option<&Value>| v.and_then(Value::as_str).is_some_and(|s| !s.is_empty()); + steps.iter().any(|c| { + let params = c.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())); + let path = names(params.get("path")); + let own_file = || match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) { + None => own_format.is_some(), + Some(f) => own_format.is_some_and(|own| super::format(f).is_some_and(|f| f.id == own)), + }; + match c.get("command").and_then(Value::as_str).unwrap_or("") { + "document.save" | "file.save" => !save && (path || own_file()), + "file.saveAs" => !save && path, + "file.saveCopy" | "file.saveAsTemplate" => path, + "document.export" | "document.exportSelection" => !export && path, + // The batch does not point these exports into `destinationFolder`. + "document.exportPdf" | "document.exportEps" | "document.exportDxf" | "document.exportForWeb" | "document.exportForOffice" => path, + // Override Export sets `path`, which Export for Screens does not read: its `folder` stays. + "document.exportForScreens" => super::screens::with_preset(s, ¶ms).is_ok_and(|p| names(p.get("folder"))), + _ => false, + } + }) +} + +/// Whether `steps` hold a Save, Save As or `document.save` step that Override Save points at each +/// data set's own file in the destination folder (`redirected`). Such a step makes that file the +/// document's own, so Save and Close then saves each data set to its own file. +fn saves_move_to_each_file(steps: &[Value], redirected: bool) -> bool { + redirected && steps.iter().any(|c| retargeting_save(c, true)) +} + +/// Whether step `c` is a Save, Save As or `document.save` that writes a file (its own `path`, or one +/// in the destination folder when `redirected`) and makes that file the document's own: a save in +/// the native template format writes a copy and leaves the document's file as it was. +fn retargeting_save(c: &Value, redirected: bool) -> bool { + if !matches!(c.get("command").and_then(Value::as_str), Some("document.save" | "file.save" | "file.saveAs")) { + return false; + } + let params = c.get("params"); + let path = params.and_then(|p| p.get("path")).and_then(Value::as_str).filter(|p| !p.is_empty()); + let format_ok = match params.and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty()) { + Some(f) => super::format(f).is_some_and(|f| f.id != "template"), + // Override Save gives the step a native file name; else its own path names the format. + None if redirected => true, + None => path.and_then(super::format_for_name).is_none_or(|f| f.id != "template"), + }; + format_ok && (redirected || path.is_some()) +} + #[cfg(not(target_arch = "wasm32"))] fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::{absolute_path, may_read}; @@ -145,6 +227,18 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result { if names.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets"))); } + let redirected = run.override_save && run.dest_folder.is_some(); + // A recorded save with a file to write gives the document a file of its own before Save and Close. + if run.destination == "saveAndClose" && !run.steps.iter().any(|c| retargeting_save(c, redirected)) { + if st.path.is_none() { + return Err(bad(cmd, "the document has never been saved")); + } + // `save_plan` writes no file for a document converted from an older version or restored by + // Data Recovery. + if st.converted || st.recovered { + return Err(bad(cmd, "save the document with Save As before Save and Close")); + } + } let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string(); let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?; let steps = filter_steps(run.steps, true, false); @@ -1292,6 +1386,213 @@ mod tests { assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}"); } + /// A saved document with a text object bound to `Name` and two data sets, `A` and `B`. + fn saved_with_two_data_sets(path: &std::path::Path) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "A"})).unwrap()["id"].as_u64().unwrap(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [id]})).unwrap(); + s.execute("dataset.capture", &json!({"name": "A"})).unwrap(); + s.execute("text.setText", &json!({"ids": [id], "text": "B"})).unwrap(); + s.execute("dataset.capture", &json!({"name": "B"})).unwrap(); + s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s + } + + fn save_and_close_over_data_sets(s: &mut Session) -> Result { + s.execute( + "action.batch", + &json!({"source": "datasets", "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}), + ) + } + + #[test] + fn batch_save_and_close_over_data_sets_saves_each_over_the_template_and_warns() { + let path = tmp("save-close-sets").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + std::fs::write(&path, b"stale").unwrap(); + let r = save_and_close_over_data_sets(&mut s).unwrap(); + assert_eq!((r["count"].as_u64(), r["failed"].as_u64()), (Some(2), Some(0)), "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + assert_ne!(std::fs::read(&path).unwrap(), b"stale", "the template's file is saved over"); + assert_eq!(file_names(path.parent().unwrap()), ["template.vectorcraft"], "{r}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset.as_deref(), Some("B"), "the last data set is applied"); + assert_eq!(st.path.as_deref(), Some(path.to_string_lossy().as_ref()), "the template stays open"); + assert!(!st.is_dirty(), "the last data set is saved"); + } + + #[test] + fn batch_warns_when_a_recorded_save_writes_one_file_for_every_data_set() { + let path = tmp("recorded-save-sets").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let save = json!([{"command": "document.save", "params": {}}]); + let r = s.execute("action.batch", &json!({"source": "datasets", "commands": save})).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + let dest = tmp("recorded-save-dest"); + let p = json!({"source": "datasets", "destination": "folder", "destinationFolder": dest.to_string_lossy(), "commands": save}); + let r = s.execute("action.batch", &p).unwrap(); + assert!(r.get("warning").is_none(), "Override Save points each save into the folder: {r}"); + let unlock = json!({"source": "datasets", "commands": [{"command": "object.unlockAll", "params": {}}]}); + let r = s.execute("action.batch", &unlock).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + } + + #[test] + fn batch_save_and_close_after_a_redirected_save_writes_each_data_set_to_its_own_file() { + let save_close = |dest: &std::path::Path| { + json!({ + "source": "datasets", + "destination": "saveAndClose", + "destinationFolder": dest.to_string_lossy(), + "commands": [{"command": "document.save", "params": {}}] + }) + }; + let path = tmp("redirected-save-close").join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let bytes = std::fs::read(&path).unwrap(); + let dest = tmp("redirected-save-close-dest"); + let p = save_close(&dest); + let mut kept = p.clone(); + kept["overrideSave"] = json!(false); + assert!(batch_overwrites_one_file(&s, &kept), "without Override Save the save stays on the template"); + let r = s.execute("action.batch", &p).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert_eq!(file_names(&dest), ["template_A.vectorcraft", "template_B.vectorcraft"], "{r}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template's file stays as it was"); + // A document never saved gets its files from the redirected save too. + let mut unsaved = with_data_sets(&["a", "b"]); + let dest = tmp("redirected-save-close-unsaved"); + let r = unsaved.execute("action.batch", &save_close(&dest)).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert_eq!(file_names(&dest), ["Untitled_a.vectorcraft", "Untitled_b.vectorcraft"], "{r}"); + // A template save writes a copy: Save and Close still saves over the document's own file. + let mut template = save_close(&dest); + template["commands"] = json!([{"command": "document.save", "params": {"format": "template"}}]); + assert!(batch_overwrites_one_file(&s, &template), "{template}"); + let mut never = with_data_sets(&["a", "b"]); + let msg = never.execute("action.batch", &template).unwrap_err().to_string(); + assert!(msg.ends_with("the document has never been saved"), "{msg}"); + // A recorded Save As with its own path gives a never-saved document its file, the same one + // for every data set. + let one = tmp("save-as-then-close").join("one.vectorcraft"); + let p = json!({ + "source": "datasets", + "destination": "saveAndClose", + "commands": [{"command": "file.saveAs", "params": {"path": one.to_string_lossy()}}] + }); + let r = never.execute("action.batch", &p).unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_eq!(r["warning"], ONE_FILE_WARNING, "{r}"); + assert!(one.exists(), "{r}"); + } + + #[test] + fn one_file_rule_covers_save_and_close_and_steps_left_in_place() { + let saved = saved_with_two_data_sets(&tmp("one-file-rule").join("template.vectorcraft")); + let unsaved = with_data_sets(&["a"]); + let with = |extra: Value| { + let mut p = json!({"source": "datasets", "commands": [{"command": "document.export", "params": {"path": "/x/a.png"}}]}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + batch_overwrites_one_file(&saved, &p) + }; + assert!(with(json!({})), "an export with its own path"); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out"}))); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "overrideExport": false}))); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "overrideSave": false}))); + assert!(!with(json!({"source": "folder"})), "a folder source opens a file per pass"); + assert!(with(json!({"destination": "saveAndClose", "commands": [{"command": "object.unlockAll"}]}))); + let saved_as = json!({"commands": [{"command": "file.saveAs", "params": {"path": "/x/a.svg"}}]}); + assert!(with(saved_as.clone())); + assert!(!with(json!({"destination": "folder", "destinationFolder": "/out", "commands": saved_as["commands"]}))); + assert!(!with(json!({"commands": [{"command": "file.saveAs", "params": {}}]})), "no path: the data comes back"); + assert!(!with(json!({"commands": [{"command": "document.export", "params": {"format": "png"}}]})), "no path: the data comes back"); + let copy = json!([{"command": "file.saveCopy", "params": {"path": "/x/a.vectorcraft"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": copy})), "Override Save leaves copies alone"); + let save = |format: Option<&str>| { + let params = format.map_or(json!({}), |f| json!({"format": f})); + json!({"source": "datasets", "commands": [{"command": "document.save", "params": params}]}) + }; + assert!(batch_overwrites_one_file(&saved, &save(None)), "the document's own file"); + assert!(batch_overwrites_one_file(&saved, &save(Some("vectorcraft"))), "the document's own format"); + assert!(!batch_overwrites_one_file(&unsaved, &save(None)), "a document never saved has no file to write"); + assert!(!batch_overwrites_one_file(&saved, &save(Some("svg"))), "another format without a path: the data comes back"); + assert!(!with(json!({"commands": [{"command": "file.saveCopy", "params": {"folder": "/x"}}]})), "a folder alone: the data comes back"); + let screens = json!([{"command": "document.exportForScreens", "params": {"folder": "/x"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": screens})), "Override Export leaves its folder"); + assert!(!with(json!({"commands": [{"command": "document.exportForScreens", "params": {}}]}))); + let preset = json!([{"command": "document.exportForScreens", "params": {"exportPreset": "Logo Package"}}]); + assert!(!with(json!({"commands": preset})), "a preset without a folder: the data comes back"); + for id in ["document.exportPdf", "document.exportEps", "document.exportDxf", "document.exportForWeb", "document.exportForOffice"] { + let step = json!([{"command": id, "params": {"path": "/x/a.out"}}]); + assert!(with(json!({"destination": "folder", "destinationFolder": "/out", "commands": step})), "{id} keeps its path"); + assert!(!with(json!({"commands": [{"command": id, "params": {}}]})), "{id} without a path: the data comes back"); + } + } + + #[test] + fn batch_refuses_save_and_close_over_data_sets_of_a_converted_or_recovered_document() { + for converted in [true, false] { + let path = tmp(if converted { "converted-sets" } else { "recovered-sets" }).join("template.vectorcraft"); + let mut s = saved_with_two_data_sets(&path); + let bytes = std::fs::read(&path).unwrap(); + let (active, revision) = { + let st = s.doc_mut().unwrap(); + if converted { + st.converted = true; + } else { + st.recovered = true; + } + (st.doc.variables.active_dataset.clone(), st.revision) + }; + let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); + assert!(msg.ends_with("save the document with Save As before Save and Close"), "{msg}"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the file is not written"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); + assert_eq!(st.revision, revision, "no step runs"); + } + } + + #[test] + fn batch_refuses_save_and_close_over_data_sets_of_an_unsaved_document() { + let mut s = with_data_sets(&["a", "b"]); + let (active, revision) = { + let st = s.doc().unwrap(); + (st.doc.variables.active_dataset.clone(), st.revision) + }; + let msg = save_and_close_over_data_sets(&mut s).unwrap_err().to_string(); + assert!(msg.ends_with("the document has never been saved"), "{msg}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.variables.active_dataset, active, "no data set is selected"); + assert_eq!(st.revision, revision, "no step runs"); + assert!(st.path.is_none()); + } + + #[test] + fn batch_save_and_close_saves_and_closes_each_source_file() { + let src = tmp("save-close-src"); + let a = write_svg(&src, "a.svg", "#f00"); + let before = std::fs::read(&a).unwrap(); + let mut s = Session::new(); + let r = s + .execute( + "action.batch", + &json!({"folder": src.to_string_lossy(), "destination": "saveAndClose", "commands": [{"command": "object.unlockAll", "params": {}}]}), + ) + .unwrap(); + assert_eq!(r["failed"], 0, "{r}"); + assert_ne!(std::fs::read(&a).unwrap(), before, "the source file is saved over"); + assert_eq!(file_names(&src), ["a.svg"], "{r}"); + assert!(s.doc().is_err(), "the source file is closed"); + } + #[test] fn batch_refuses_nesting_and_empty_commands() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 011d025e3..b71ede096 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 4320cbb9c..32b87d80d 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat}; +use vectorcraft_cad::{ColorDepth, DxfOptions, DxfPreset, DxfVersion, Preserve, RasterFormat}; use vectorcraft_doc::{Document, Unit}; use vectorcraft_geom::Rect; @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", has_doc, export_dxf )] @@ -33,6 +33,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS, + FormatOption { + name: "dxfPreset", + ty: "string", + default: "\"custom\"", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { name: "unit", @@ -120,6 +126,7 @@ impl Unsupported { struct Params { #[serde(flatten)] boards: ArtboardPick, + dxf_preset: Option, version: Option, unit: Option, scale: Option, @@ -150,7 +157,8 @@ fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, d /// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice. fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() }; - let d = DxfOptions::default(); + // The options a preset sets are the defaults of those `p` leaves out. + let d = choice("dxfPreset", q.dxf_preset, DxfPreset::from_id, DxfPreset::Custom, "custom or cnc")?.options(); let scale = q.scale.unwrap_or(d.scale); if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) { return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end()))); @@ -181,6 +189,11 @@ pub fn check(p: &Value) -> std::result::Result<(), String> { parse(p).map(|_| ()).map_err(|e| e.to_string()) } +/// The DXF options in `p` (the region and crop left at their defaults). +pub fn options(p: &Value) -> std::result::Result { + parse(p).map(|(o, _)| o).map_err(|e| e.to_string()) +} + /// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner /// (default the first), or with `use_artboards` one drawing per chosen artboard (default all) /// holding the art over it. diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 13564614b..0869ad850 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -214,10 +214,11 @@ 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. +/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected, +/// the instance is exported whole. pub(crate) fn selected_artwork(s: &Session) -> Result> { - let ids = edit::selected_roots(s)?; let st = s.doc()?; + let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)); let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); diff --git a/crates/engine/src/cmd/fileio/export_names.rs b/crates/engine/src/cmd/fileio/export_names.rs index af0e49563..5f2c14bfd 100644 --- a/crates/engine/src/cmd/fileio/export_names.rs +++ b/crates/engine/src/cmd/fileio/export_names.rs @@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json}; /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without /// case: `Icon` and `icon` are one file on most desktop file systems). pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { - unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))) + unique_file_names(artboard_names(doc, boards)) +} + +/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens, +/// which puts every stem through `safe_file_name` after this (trailing periods, device names). +pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { + unique_file_names_keeping_dots(artboard_names(doc, boards)) +} + +/// Each of `boards`' name, with the file name to use when it has none. +fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator { + boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))) } /// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe /// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used /// (compared without case). -pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { +fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { distinct_names(names.into_iter().map(|(name, fallback)| { let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); if base.is_empty() { fallback } else { base } })) } +/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as +/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the +/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs +/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names. +pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator) -> Vec { + distinct_names(names.into_iter().map(|(name, fallback)| { + let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect(); + if base.trim_matches('.').is_empty() { + return fallback; + } + // A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before. + let rest = base.trim_start_matches('.'); + format!("{}{rest}", "-".repeat(base.len() - rest.len())) + })) +} + /// `names` with `-2`, `-3`… after a name already used (compared without case). pub(super) fn distinct_names(names: impl IntoIterator) -> Vec { distinct_names_by(names, str::to_lowercase) diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 33ecfa8ab..ef87ea1b6 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -503,6 +503,8 @@ pub const FORMATS: &[Format] = &[ reader("pxr", "Pixar", &["pxr"], "image/x-pixar", true), // JP2 and raw codestreams are decoded to PNG; JPX composition is not supported. reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true), + // HEVC-coded HEIF still images, decoded to PNG; image sequences are not read. + reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -537,6 +539,8 @@ pub const OPEN_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", @@ -554,11 +558,13 @@ pub const OPEN_EXTS: &[&str] = &[ /// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to /// raster editors, so Finder and file managers don't offer a vector app for them, and there is no -/// `.epsf`, `.ps`, Targa, PCX, Pixar or JPEG 2000 type, nor one for the `.pns`, `.dib` and `.rle` names of +/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. -pub const UNASSOCIATED_EXTS: &[&str] = - &["psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"]; +pub const UNASSOCIATED_EXTS: &[&str] = &[ + "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", + "heic", "heif", +]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares /// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order. @@ -621,6 +627,8 @@ pub const PLACE_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", diff --git a/crates/engine/src/cmd/fileio/heifread.rs b/crates/engine/src/cmd/fileio/heifread.rs new file mode 100644 index 000000000..4867022de --- /dev/null +++ b/crates/engine/src/cmd/fileio/heifread.rs @@ -0,0 +1,627 @@ +//! HEIC and HEIF images for File › Open and File › Place, decoded with heic-rs when the engine's +//! `heif` feature is on (the default). The image is bounded before decoding and converted to RGBA +//! for the common PNG storage path. A build without the feature still recognizes the files and +//! refuses them with a message. + +#[cfg(all(test, feature = "heif"))] +pub(super) use decoder::{active_sps, picture_bytes, sps_signal, sps_size}; +#[cfg(feature = "heif")] +pub(super) use decoder::{decode, quarter_turned, with_exif}; + +/// The brands heic-rs 0.1.4 reads: HEVC-coded images and image sequences (ISO/IEC 23008-12 annex +/// B) and the general image and sequence brands. +const BRANDS: [&[u8; 4]; 7] = [b"heic", b"heix", b"heim", b"heis", b"hevc", b"mif1", b"msf1"]; + +/// Recognize a HEIF file by its `ftyp` box: one of [`BRANDS`] as the major brand or a compatible +/// one, and no AVIF brand (`avif`, `avis`), as heic-rs refuses a file that lists one. The box is +/// read as JP2 boxes are ([`super::jp2read::boxes`]): ISO BMFF sizes, the 64-bit one included. +pub(super) fn is_heif(bytes: &[u8]) -> bool { + let Some((b"ftyp", ftyp)) = super::jp2read::boxes(bytes).next() else { return false }; + let Some(major) = ftyp.get(..4).and_then(|b| <&[u8; 4]>::try_from(b).ok()) else { return false }; + if ftyp.len() < 8 { + return false; + } + let compatible = ftyp.get(8..).unwrap_or_default().as_chunks::<4>().0; + let brands = || std::iter::once(major).chain(compatible); + !brands().any(|brand| matches!(brand, b"avif" | b"avis")) && brands().any(|brand| BRANDS.contains(&brand)) +} + +/// Without the `heif` feature every HEIF file is refused. +#[cfg(not(feature = "heif"))] +pub(super) fn decode(_bytes: &[u8], _max_alloc: u64) -> Result { + Err("HEIC and HEIF images can't be opened in this build".into()) +} + +/// Without the `heif` feature no image is decoded, so none is turned. +#[cfg(not(feature = "heif"))] +pub(super) fn quarter_turned(_bytes: &[u8]) -> bool { + false +} + +/// Without the `heif` feature no resolution is read. +#[cfg(not(feature = "heif"))] +pub(super) fn with_exif(_bytes: &[u8], _read: impl FnOnce(&[u8]) -> Option) -> Option { + None +} + +#[cfg(feature = "heif")] +mod decoder { + use heic_rs::context::Context; + use heic_rs::{DecodeOptions, Error, MatrixCoefficients, Nclx, PixelLayout, Range}; + + /// The message for a file that isn't a readable HEIF image. + const UNREADABLE: &str = "the HEIC or HEIF file can't be read"; + /// The message for an image sequence, an overlay, an image that isn't HEVC-coded, a grid whose + /// tiles are cropped, turned or mirrored, or an HEVC coding tool the decoder doesn't implement. + const UNSUPPORTED: &str = "the HEIC or HEIF file holds a kind of image that can't be read"; + + /// Decode the primary image within `max_alloc` bytes, counting the decoder's planes, the RGBA + /// output and the copy that rotating or cropping makes, with the container's rotation, mirror + /// and crop applied. An embedded RGB ICC profile, or `nclx` Display P3 primaries with the sRGB + /// curve, is converted to sRGB; other `nclx` values are read as sRGB. A panic inside the + /// decoder is reported as an unreadable file. + pub(in super::super) fn decode(bytes: &[u8], max_alloc: u64) -> Result { + crate::guard::catch_panic(|| decode_unguarded(bytes, max_alloc)).unwrap_or_else(|_| Err(UNREADABLE.into())) + } + + /// Whether the container's rotations of the primary image add up to an odd number of quarter + /// turns, so its width and height swap. The decoder applies every `irot` the item lists. + pub(in super::super) fn quarter_turned(bytes: &[u8]) -> bool { + let turned = || { + let ctx = Context::open(bytes).ok()?; + let props = ctx.props(ctx.meta.primary).ok()?; + let quarters = props.transforms.iter().filter(|t| matches!(t, heic_rs::props::Transform::Rotate(r) if r.swaps_axes())).count(); + Some(quarters % 2 == 1) + }; + crate::guard::catch_panic(turned).ok().flatten().unwrap_or(false) + } + + /// `read` of the primary image's EXIF block, a TIFF structure inside `bytes`, for its + /// resolution tags. + pub(in super::super) fn with_exif(bytes: &[u8], read: impl FnOnce(&[u8]) -> Option) -> Option { + let find = || { + let ctx = Context::open(bytes).ok()?; + ctx.exif(ctx.meta.primary).ok().flatten() + }; + crate::guard::catch_panic(find).ok().flatten().and_then(read) + } + + fn decode_unguarded(bytes: &[u8], max_alloc: u64) -> Result { + let refused = |e: Error| match e { + Error::Unsupported(_) | Error::MissingBox("hvcC" | "meta") => UNSUPPORTED.to_string(), + _ => UNREADABLE.to_string(), + }; + let info = heic_rs::probe(bytes).map_err(refused)?; + let ctx = Context::open(bytes).map_err(refused)?; + if unsupported_items(&ctx).ok_or(UNREADABLE)? { + return Err(UNSUPPORTED.into()); + } + // The stored size, before a crop: the size the decoder holds. + let too_large = || format!("an image of {} × {} pixels is too large to draw", info.coded_width, info.coded_height); + let declared = u64::from(info.coded_width).checked_mul(u64::from(info.coded_height)).ok_or_else(too_large)?; + // A grid is composed at the size its own data gives, which need not match `ispe`. + let grid = grid_pixels(&ctx).ok_or(UNREADABLE)?; + // A single picture is converted at the size its stream gives, which need not match `ispe` + // either. + let coded = declared.max(grid).max(stream_pixels(&ctx).ok_or(UNREADABLE)?); + let shown = u64::from(info.width).checked_mul(u64::from(info.height)).ok_or_else(too_large)?; + // The RGBA output, and a second RGBA image when the decoder rotates, mirrors or crops it, + // which is no larger than the first. + let output = coded.checked_mul(8).ok_or_else(too_large)?; + let pictures = picture_bytes_in(&ctx).ok_or(UNREADABLE)?; + // An alpha grid is composed into a frame of 16-bit planes (at most three samples a pixel), + // and so is the image's grid when it is decoded with a color description of its tiles or + // its stream. A single picture is converted in place. + let composed = if grid > 0 { coded.checked_mul(if info.has_alpha { 12 } else { 6 }).ok_or_else(too_large)? } else { 0 }; + if coded == 0 || shown == 0 || output.checked_add(pictures).and_then(|n| n.checked_add(composed)).is_none_or(|n| n > max_alloc) { + return Err(too_large()); + } + let max_pixels = max_alloc / 4; + let decoded = match tile_nclx(&ctx).or_else(|| stream_nclx(&ctx)) { + Some(nclx) => decode_with_nclx(&ctx, nclx, max_pixels), + None => heic_rs::decode(bytes, &DecodeOptions::default().with_layout(PixelLayout::Rgba8).with_max_pixels(Some(max_pixels))), + } + .map_err(|e| match e { + Error::PixelLimit { .. } | Error::BoxTooLarge { .. } => too_large(), + e => refused(e), + })?; + let mut img = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.data).ok_or(UNREADABLE)?; + if let Some(profile) = source_profile(&ctx) { + for pixel in img.pixels_mut() { + let [r, g, b, a] = pixel.0; + let device = [r, g, b].map(|v| f32::from(v) / 255.0); + if let Some(rgb) = profile.to_srgb_into(&device, vectorcraft_color::cms::Intent::RelativeColorimetric) { + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + pixel.0 = [r, g, b, a]; + } + } + } + Ok(img) + } + + /// The primary image and its alpha image, when it has one. + fn primary_and_alpha(ctx: &Context<'_>) -> Option> { + let primary = ctx.meta.primary; + Some(std::iter::once(primary).chain(ctx.alpha_item(primary).ok()?)) + } + + /// Whether the primary image or its alpha image is neither an HEVC-coded item (`hvc1`, `hev1`) + /// nor a grid of them, or is a grid with a tile that carries a crop, rotation or mirror. heic-rs + /// 0.1.4 decodes any item with an `hvcC` whatever its type, and composes a grid from its tiles + /// as coded, without their transforms: an encoder that pads tiles smaller than its coding block + /// (64 × 64 for x265) and crops them back with `clap` gets the wrong pixels. + fn unsupported_items(ctx: &Context<'_>) -> Option { + let hevc = |id: u32| ctx.meta.item(id).is_some_and(|info| info.is_hevc()); + for item in primary_and_alpha(ctx)? { + let Some((_, tiles)) = ctx.grid(item).ok()? else { + if !hevc(item) { + return Some(true); + } + continue; + }; + for tile in tiles { + if !hevc(tile) || !ctx.props(tile).ok()?.transforms.is_empty() { + return Some(true); + } + } + } + Some(false) + } + + /// The pixels of the larger of the primary image's grid and its alpha image's grid, at the + /// output size each grid gives; 0 when neither is a grid. + fn grid_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if let Some((grid, _)) = ctx.grid(item).ok()? { + largest = largest.max(u64::from(grid.output_width) * u64::from(grid.output_height)); + } + } + Some(largest) + } + + /// The pixels of the larger of the primary image and its alpha image when either is a single + /// picture, at the size of its largest sequence parameter set (before its conformance window); + /// 0 when both are grids. `None` when a size can't be read. + fn stream_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if ctx.grid(item).ok()?.is_some() { + continue; + } + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = ctx.props(item).ok()?.hvcc else { continue }; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, _) = sps_size(nal)?; + largest = largest.max(width.checked_mul(height)?); + } + } + Some(largest) + } + + /// The first tile's `nclx` when the primary image is a grid without an `nclx` of its own and + /// its tiles carry one. heic-rs 0.1.4 converts a grid's samples to RGB with the grid item's + /// `nclx` alone, and with its BT.709 limited-range default when the grid has none + /// (tbraun96/heic-rs#6). + fn tile_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let (_, tiles) = ctx.grid(primary).ok()??; + ctx.props(*tiles.first()?).ok()?.nclx + } + + /// The color description in the HEVC stream's video usability information (ITU-T H.265 + /// E.2.1) of the primary image, or of its grid's first tile, when neither carries an `nclx` + /// (a file whose only color box is an ICC profile, or one without a color box). heic-rs 0.1.4 + /// reads only the container's `nclx` and otherwise converts with BT.709 limited range. + fn stream_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let coded = match ctx.grid(primary).ok()? { + Some((_, tiles)) => *tiles.first()?, + None => primary, + }; + let props = ctx.props(coded).ok()?; + if props.nclx.is_some() { + return None; + } + let hvcc = props.hvcc?; + let data = ctx.item_data(coded).ok()?; + let slices = hvcc.split_nals(&data).ok()?; + active_sps(&hvcc.parameter_sets(), &slices).and_then(sps_signal) + } + + /// The SPS the first slice of `slices` uses: the one its PPS names (ITU-T H.265 7.4.3.3.1). + pub(in super::super) fn active_sps<'a>(parameter_sets: &[&'a [u8]], slices: &[&[u8]]) -> Option<&'a [u8]> { + let slice = slices.iter().find(|nal| nal_type(nal).is_some_and(|t| t < 32))?; + let bits = &mut Bits { data: &payload(slice), at: 0 }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag in an IRAP picture (types 16 + // to 23), slice_pic_parameter_set_id. + bits.skip(1)?; + if nal_type(slice).is_some_and(|t| (16..=23).contains(&t)) { + bits.skip(1)?; + } + let pps_id = bits.exp_golomb()?; + // pps_pic_parameter_set_id, pps_seq_parameter_set_id. + let pps_ids = |nal: &[u8]| { + let bits = &mut Bits { data: &payload(nal), at: 0 }; + Some((bits.exp_golomb()?, bits.exp_golomb()?)) + }; + let sps_id = parameter_sets.iter().filter(|nal| nal_type(nal) == Some(PPS)).find_map(|nal| pps_ids(nal).filter(|(id, _)| *id == pps_id))?.1; + let sps_ids = |nal: &[u8]| { + let rbsp = sps_payload(nal)?; + sps_head(&mut Bits { data: &rbsp, at: 0 }).map(|(_, id, ..)| id) + }; + parameter_sets.iter().copied().find(|nal| sps_ids(nal) == Some(sps_id)) + } + + /// The primary image decoded as heic-rs 0.1.4 decodes it (a grid's tiles one after another), + /// with its samples converted to RGBA with `nclx`, the alpha image applied and the image's + /// rotation, mirror and crop applied. + fn decode_with_nclx(ctx: &Context<'_>, nclx: Nclx, max_pixels: u64) -> heic_rs::Result { + use heic_rs::hevc::Frame; + + fn coded(ctx: &Context<'_>, id: u32) -> heic_rs::Result { + let props = ctx.props(id)?; + let hvcc = props.hvcc.as_ref().ok_or(Error::MissingBox("hvcC"))?; + let data = ctx.item_data(id)?; + let frame = heic_rs::hevc::decode_still(&hvcc.parameter_sets(), &hvcc.split_nals(&data)?)?; + frame.validate()?; + Ok(frame) + } + fn item(ctx: &Context<'_>, id: u32, max_pixels: u64) -> heic_rs::Result { + match ctx.grid(id)? { + Some((grid, tiles)) => { + // The tiles decode in parallel on native, as heic-rs's own decode does. + #[cfg(not(target_arch = "wasm32"))] + let frames = { + use rayon::prelude::*; + tiles.par_iter().map(|&t| coded(ctx, t)).collect::>>()? + }; + #[cfg(target_arch = "wasm32")] + let frames = tiles.iter().map(|&t| coded(ctx, t)).collect::>>()?; + heic_rs::grid::compose(&grid, &frames, max_pixels) + } + None => coded(ctx, id), + } + } + + let primary = ctx.meta.primary; + let props = ctx.props(primary)?; + let frame = item(ctx, primary, max_pixels)?; + let alpha = ctx.alpha_item(primary)?.map(|a| item(ctx, a, max_pixels)).transpose()?; + // `None`: rayon's global pool on native, where heic-rs is built with `parallel`. + let image = heic_rs::color::convert(&frame, alpha.as_ref(), nclx, PixelLayout::Rgba8, max_pixels, None)?; + heic_rs::transform::apply_all(image, &props.transforms) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures of `bytes` it decodes (see + /// [`picture_bytes_in`]); `None` when they can't be read. + #[cfg(test)] + pub(in super::super) fn picture_bytes(bytes: &[u8]) -> Option { + picture_bytes_in(&Context::open(bytes).ok()?) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures it decodes: the primary image's, + /// or each tile of its grid, and its alpha image's the same way. Each picture is sized by the + /// largest of its sequence parameter sets (the size the decoder allocates, rounded up to whole + /// 64-sample coding blocks), not by the container, and takes three sets of 16-bit planes: the + /// picture, a filtered copy and the frame returned. `None` when an item's sizes can't be read. + fn picture_bytes_in(ctx: &Context<'_>) -> Option { + let mut total = 0u64; + for item in primary_and_alpha(ctx)? { + let coded = match ctx.grid(item).ok()? { + Some((_, tiles)) => tiles, + None => vec![item], + }; + for id in coded { + let props = ctx.props(id).ok()?; + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = props.hvcc else { continue }; + // The slices pick their SPS through the PPS, so the largest one is counted. Every + // SPS must be read. + let mut largest = None; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, chroma) = sps_size(nal)?; + let blocks = |n: u64| n.div_ceil(64) * 64; + // Bytes a sample position takes in one set of planes: luma and the two chroma + // planes. + let planes = match chroma { + 0 => 2, + 1 => 3, + 2 => 4, + _ => 6, + }; + let picture = blocks(width).checked_mul(blocks(height))?.checked_mul(planes * 3)?; + largest = largest.max(Some(picture)); + } + total = total.checked_add(largest?)?; + } + } + Some(total) + } + + /// The NAL unit type of an HEVC sequence parameter set. + const SPS: u8 = 33; + + /// The type of an HEVC NAL unit, from its header. + fn nal_type(nal: &[u8]) -> Option { + nal.first().map(|b| (b >> 1) & 0x3f) + } + + /// The NAL unit type of an HEVC picture parameter set. + const PPS: u8 = 34; + + /// A NAL unit's payload, without its two-byte header and the emulation prevention bytes (the 03 + /// of 00 00 03), up to its first 4,096 bytes. + fn payload(nal: &[u8]) -> Vec { + let mut rbsp = Vec::with_capacity(256); + let mut zeros = 0; + for &b in nal.get(2..).unwrap_or_default().iter().take(4096) { + if b == 3 && zeros >= 2 { + zeros = 0; + continue; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + rbsp.push(b); + } + rbsp + } + + /// An SPS NAL unit's [`payload`]; `None` for another NAL unit. + fn sps_payload(nal: &[u8]) -> Option> { + (nal_type(nal) == Some(SPS)).then(|| payload(nal)) + } + + /// The fields of an SPS (ITU-T H.265 7.3.2.2) up to the picture height: + /// `sps_max_sub_layers_minus1`, `sps_seq_parameter_set_id`, the width, the height and + /// `chroma_format_idc`. + fn sps_head(bits: &mut Bits<'_>) -> Option<(u64, u64, u64, u64, u64)> { + // sps_video_parameter_set_id, sps_max_sub_layers_minus1, sps_temporal_id_nesting_flag. + bits.skip(4)?; + let sub_layers = bits.read(3)?; + bits.skip(1)?; + // profile_tier_level (7.3.3): the general profile, tier and level, then the sub-layers'. + bits.skip(96)?; + let mut present = Vec::new(); + for _ in 0..sub_layers { + present.push((bits.read(1)?, bits.read(1)?)); + } + if sub_layers > 0 { + bits.skip(2 * (8 - sub_layers as usize))?; + } + for (profile, level) in present { + bits.skip(88 * profile as usize + 8 * level as usize)?; + } + let id = bits.exp_golomb()?; + let chroma = bits.exp_golomb()?; + if chroma == 3 { + bits.skip(1)?; + } + let width = bits.exp_golomb()?; + let height = bits.exp_golomb()?; + Some((sub_layers, id, width, height, chroma)) + } + + /// The picture width, height and `chroma_format_idc` of an HEVC sequence parameter set NAL + /// unit, before its conformance window; `None` for another NAL unit. + pub(in super::super) fn sps_size(nal: &[u8]) -> Option<(u64, u64, u64)> { + let rbsp = sps_payload(nal)?; + let (_, _, width, height, chroma) = sps_head(&mut Bits { data: &rbsp, at: 0 })?; + Some((width, height, chroma)) + } + + /// The video signal type of an HEVC sequence parameter set NAL unit's VUI (ITU-T H.265 E.2.1) + /// as an `nclx`: the full-range flag and, when the VUI describes them, the color primaries, + /// transfer characteristics and matrix coefficients (otherwise 2, unspecified). `None` when the + /// SPS has no VUI, the VUI has no video signal type, or the SPS can't be read that far. + pub(in super::super) fn sps_signal(nal: &[u8]) -> Option { + let rbsp = sps_payload(nal)?; + let bits = &mut Bits { data: &rbsp, at: 0 }; + let (sub_layers, ..) = sps_head(bits)?; + skip_to_vui(bits, sub_layers)?; + // vui_parameters_present_flag, then aspect_ratio_info_present_flag (an Extended_SAR + // aspect_ratio_idc of 255 adds its 32-bit ratio) and overscan_info_present_flag. + if bits.read(1)? == 0 { + return None; + } + if bits.read(1)? == 1 && bits.read(8)? == 255 { + bits.skip(32)?; + } + if bits.read(1)? == 1 { + bits.skip(1)?; + } + // video_signal_type_present_flag: video_format, video_full_range_flag and the color + // description. + if bits.read(1)? == 0 { + return None; + } + bits.skip(3)?; + let range = if bits.read(1)? == 1 { Range::Full } else { Range::Limited }; + let (primaries, transfer, matrix) = if bits.read(1)? == 1 { (bits.read(8)?, bits.read(8)?, bits.read(8)?) } else { (2, 2, 2) }; + let code = |v: u64| u16::try_from(v).ok(); + let matrix_code = code(matrix)?; + Some(Nclx { primaries: code(primaries)?, transfer: code(transfer)?, matrix: MatrixCoefficients::from_code(matrix_code), matrix_code, range }) + } + + /// Skips the fields of an SPS after [`sps_head`], up to `vui_parameters_present_flag`. + fn skip_to_vui(bits: &mut Bits<'_>, sub_layers: u64) -> Option<()> { + // conformance_window_flag and its four offsets. + if bits.read(1)? == 1 { + for _ in 0..4 { + bits.exp_golomb()?; + } + } + // bit_depth_luma_minus8, bit_depth_chroma_minus8. + bits.exp_golomb()?; + bits.exp_golomb()?; + let poc_bits = bits.exp_golomb()?.checked_add(4).filter(|&n| n <= 16)?; + // sps_sub_layer_ordering_info_present_flag, then three values for each sub-layer given. + let ordered = if bits.read(1)? == 1 { sub_layers + 1 } else { 1 }; + for _ in 0..ordered * 3 { + bits.exp_golomb()?; + } + // The coding and transform block sizes and the transform hierarchy depths. + for _ in 0..6 { + bits.exp_golomb()?; + } + // scaling_list_enabled_flag, sps_scaling_list_data_present_flag. + if bits.read(1)? == 1 && bits.read(1)? == 1 { + skip_scaling_lists(bits)?; + } + // amp_enabled_flag, sample_adaptive_offset_enabled_flag. + bits.skip(2)?; + // pcm_enabled_flag: the PCM sample depths, block sizes and loop filter flag. + if bits.read(1)? == 1 { + bits.skip(8)?; + bits.exp_golomb()?; + bits.exp_golomb()?; + bits.skip(1)?; + } + skip_reference_picture_sets(bits)?; + // long_term_ref_pics_present_flag: each picture's POC bits and used flag. + if bits.read(1)? == 1 { + let count = bits.exp_golomb()?; + if count > 32 { + return None; + } + for _ in 0..count { + bits.skip(poc_bits as usize + 1)?; + } + } + // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag. + bits.skip(2) + } + + /// Skips `scaling_list_data()` (ITU-T H.265 7.3.4). + fn skip_scaling_lists(bits: &mut Bits<'_>) -> Option<()> { + for size in 0..4 { + // The 32 × 32 lists are given for matrices 0 and 3 only. + for _ in 0..if size == 3 { 2 } else { 6 } { + if bits.read(1)? == 0 { + // scaling_list_pred_matrix_id_delta. + bits.exp_golomb()?; + } else { + // scaling_list_dc_coef_minus8, then each scaling_list_delta_coef (se(v) codes, + // as long as ue(v) codes). + if size > 1 { + bits.exp_golomb()?; + } + for _ in 0..if size == 0 { 16 } else { 64 } { + bits.exp_golomb()?; + } + } + } + } + Some(()) + } + + /// Skips `num_short_term_ref_pic_sets` and each `st_ref_pic_set()` (ITU-T H.265 7.3.7) of an + /// SPS. A set predicted from the one before it has one or two flags for each of that set's + /// pictures and one more. + fn skip_reference_picture_sets(bits: &mut Bits<'_>) -> Option<()> { + let sets = bits.exp_golomb()?; + if sets > 64 { + return None; + } + // NumDeltaPocs of each set read so far. + let mut pictures: Vec = Vec::new(); + for index in 0..sets { + let predicted = index != 0 && bits.read(1)? == 1; + let count = if predicted { + // delta_rps_sign, abs_delta_rps_minus1. + bits.skip(1)?; + bits.exp_golomb()?; + let mut count = 0; + for _ in 0..=*pictures.last()? { + // used_by_curr_pic_flag, and use_delta_flag when it is 0. + if bits.read(1)? == 1 || bits.read(1)? == 1 { + count += 1; + } + } + count + } else { + let (negative, positive) = (bits.exp_golomb()?, bits.exp_golomb()?); + if negative > 16 || positive > 16 { + return None; + } + // Each picture's delta_poc_s0_minus1 or delta_poc_s1_minus1 and its used flag. + for _ in 0..negative + positive { + bits.exp_golomb()?; + bits.skip(1)?; + } + negative + positive + }; + pictures.push(count); + } + Some(()) + } + + /// A big-endian bit reader over an RBSP. + struct Bits<'a> { + data: &'a [u8], + at: usize, + } + + impl Bits<'_> { + fn bit(&mut self) -> Option { + let byte = self.data.get(self.at / 8)?; + let bit = (byte >> (7 - self.at % 8)) & 1; + self.at += 1; + Some(u64::from(bit)) + } + + fn read(&mut self, n: u32) -> Option { + (0..n).try_fold(0u64, |v, _| Some(v << 1 | self.bit()?)) + } + + fn skip(&mut self, n: usize) -> Option<()> { + self.at = self.at.checked_add(n)?; + (self.at <= self.data.len() * 8).then_some(()) + } + + /// An unsigned Exp-Golomb code, ue(v), of at most 32 leading zeros. + fn exp_golomb(&mut self) -> Option { + let mut zeros = 0; + while self.bit()? == 0 { + zeros += 1; + if zeros > 32 { + return None; + } + } + Some((1u64 << zeros) - 1 + self.read(zeros)?) + } + } + + /// `nclx` color primaries for Display P3 (SMPTE EG 432-1) and transfer characteristics for + /// the sRGB curve (IEC 61966-2-1), ITU-T H.273. + const P3_PRIMARIES: u16 = 12; + const SRGB_TRANSFER: u16 = 13; + + /// The profile the decoded RGB values are in when it isn't sRGB: the primary item's RGB ICC + /// profile, else Display P3 when its `nclx` names P3 primaries with the sRGB curve. A grid + /// without its own `colr` takes its first tile's, as cameras write it on the tiles. A profile + /// that can't be read, or isn't RGB, is left out and the values are read as sRGB. Without any + /// `colr`, the HEVC stream's color description ([`stream_nclx`]) is read the same way. + fn source_profile(ctx: &Context<'_>) -> Option { + use vectorcraft_color::cms::{self, IccProfile, ProfileKind}; + + let id = ctx.meta.primary; + let own = ctx.props(id).ok()?; + let described = |p: &heic_rs::props::ItemProps<'_>| p.icc.is_some() || p.nclx.is_some(); + let tile = || ctx.grid(id).ok().flatten().and_then(|(_, tiles)| tiles.first().and_then(|&t| ctx.props(t).ok())).filter(described); + let props = if described(&own) { Some(own) } else { tile() }; + if let Some(icc) = props.as_ref().and_then(|p| p.icc) { + return IccProfile::from_bytes(None, icc).ok().filter(|p| p.kind == ProfileKind::Rgb); + } + let nclx = match props { + Some(props) => props.nclx?, + None => stream_nclx(ctx)?, + }; + (nclx.primaries == P3_PRIMARIES && nclx.transfer == SRGB_TRANSFER).then(|| cms::icc::builtin_rgb(cms::DISPLAY_P3)).flatten() + } +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index a1ec671aa..697f3c1d7 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -29,6 +29,8 @@ pub struct Loaded { /// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art /// that it carries (its layers), not from its PDF part alone. pub editing_data: bool, + /// Whether the native source declared a PDF; other formats carry no native save preference. + pub native_pdf_compatible: Option, } impl Loaded { @@ -103,6 +105,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if super::jp2read::is_jpeg2000(bytes) { return format("jpeg2000"); } + if super::heifread::is_heif(bytes) { + return format("heif"); + } if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } @@ -338,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let mut converted = false; let mut preview_only = false; let mut editing_data = false; + let mut native_pdf_compatible = None; let (mut doc, mut warnings, restored) = match format.id { // EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read. _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { @@ -350,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "vectorcraft" | "template" => { let (d, info, warnings) = native_file(bytes)?; + native_pdf_compatible = Some(info.pdf_compatible); converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; (d, warnings, false) } @@ -475,11 +482,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // Threaded type read from a file flows through its frames (the threaded type of a restored // document keeps its text as saved), and every frame of a thread takes its first frame's - // Optical Margin Alignment setting. + // Optical Margin Alignment setting and writing direction. When that changes the direction of a + // frame of a restored document, the story flows through that frame's thread again. Threaded + // frames keep their size: Auto Size is off for them. if !doc.text_threads.is_empty() && !restored { crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { + crate::cmd::threads::keep_frame_sizes(&mut doc); crate::cmd::threads::share_optical_margin(&mut doc); + crate::cmd::threads::share_orientation(&mut doc); // A frame whose first paragraph continues from the frame before draws it without the // paragraph's first-line indent and list marker. crate::cmd::threads::mark_continued(&mut doc); @@ -493,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result doc.title = title; } warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); - Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) + Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible }) } /// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's @@ -553,13 +564,14 @@ impl ReadFile { /// The first half of [`open_bytes_with`], which needs no session (an app may do it on another /// thread): read and decode the file, read its linked files and lay out its type → what -/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, +/// [`open_read`] opens. Type in the file's composite fonts is measured again once the document +/// opens and its composite fonts are registered. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update /// Links set to Automatically; see [`crate::cmd::links::resolve`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; let mut loaded = load_with(name, bytes, &opts)?; - let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); + let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) } @@ -579,14 +591,15 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } - let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); + let base = crate::cmd::links::LinkBase::file(path); + let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) } /// Make a loaded file the new active document; `links` is what became of its linked files, and /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { - let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs); + let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing // fallbacks must ask before overwriting the richer source they could not fully read. let losses: Vec = warnings @@ -618,11 +631,15 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Option { /// [`super::pcxread::decode`], which refuses an image past [`MAX_RASTER_ALLOC`] before decoding. /// A Pixar picture file is recognized by its magic number ([`super::pxrread::is_pxr`]) and read by /// [`super::pxrread::decode`], which does the same. +/// A HEIC or HEIF file is recognized by its `ftyp` brands ([`super::heifread::is_heif`]) and read +/// by [`super::heifread::decode`], which does the same. /// A Targa file, which starts with no signature, is recognized by its header /// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores /// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels @@ -740,6 +760,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { if super::jp2read::is_jpeg2000(bytes) { return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } + if super::heifread::is_heif(bytes) { + // The EXIF resolution describes the stored pixels; a quarter turn swaps its axes. + let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) }); + return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + } if super::pxrread::is_pxr(bytes) { return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } diff --git a/crates/engine/src/cmd/fileio/mesh3d.rs b/crates/engine/src/cmd/fileio/mesh3d.rs new file mode 100644 index 000000000..50a919d83 --- /dev/null +++ b/crates/engine/src/cmd/fileio/mesh3d.rs @@ -0,0 +1,260 @@ +//! 3D objects as mesh files, for a 3D asset of Window › Asset Export (`assets.export3D`): OBJ with +//! its MTL materials, glTF 2.0 (one `.gltf` file, its buffer embedded as a data URI), USDA and +//! USDZ (the USDA layer in an uncompressed ZIP, its data on a 64-byte boundary). +//! +//! Each solid of the object ([`vectorcraft_render::effects::three_d_meshes`]) is its own mesh, with a +//! material in the color it is painted (solids of one color share it). Positions are y up and +//! about the point the object turns about: in points in OBJ and USD (whose layer says how many +//! meters a unit is), in meters in glTF, as its specification asks. glTF and USD colors are linear, +//! as their specifications ask; MTL colors are the display colors. Materials are two-sided, since +//! an open solid or a turned plane is seen from both sides. +//! +//! Formats: glTF 2.0 , USD +//! (USDZ: ). + +use std::fmt::Write as _; + +use serde_json::{Value, json}; +use vectorcraft_render::effects::SolidMesh; + +/// The formats a 3D asset exports to, as `assets.set3DFormat` names them: USDA first, the default. +pub const FORMATS_3D: [&str; 4] = ["usda", "usdz", "gltf", "obj"]; +/// The format a new 3D asset exports to. +pub const DEFAULT_FORMAT_3D: &str = "usda"; +/// The meters in a point (1/72 inch). +const METERS_PER_POINT: f64 = 0.0254 / 72.0; + +/// The label of `format` for menus: `USDA`, `USDZ`, `glTF`, `OBJ`. +pub fn format_3d_label(format: &str) -> &'static str { + match format { + "usdz" => "USDZ", + "gltf" => "glTF", + "obj" => "OBJ", + _ => "USDA", + } +} + +/// `stem` as an OBJ file name: whitespace and `#` become `_`, so the `mtllib` line names the MTL +/// file as one word. +fn obj_stem(stem: &str) -> String { + stem.chars().map(|c| if c.is_whitespace() || c == '#' { '_' } else { c }).collect() +} + +/// The names of the files [`mesh_files`] writes for `stem` in `format`. +pub(crate) fn mesh_file_names(stem: &str, format: &str) -> Result, String> { + match format { + "obj" => { + let stem = obj_stem(stem); + Ok(vec![format!("{stem}.obj"), format!("{stem}.mtl")]) + } + "gltf" | "usda" | "usdz" => Ok(vec![format!("{stem}.{format}")]), + _ => { + let key = "format"; + Err(format!("`{key}` must be one of {}", FORMATS_3D.join(", "))) + } + } +} + +/// The files of a 3D object named `stem` in `format` ([`FORMATS_3D`]): `(file name, bytes)`, the +/// OBJ with its MTL after it. +pub(crate) fn mesh_files(stem: &str, format: &str, parts: &[SolidMesh]) -> Result)>, String> { + let names = mesh_file_names(stem, format)?; + let bytes = match format { + "obj" => { + let (obj, mtl) = obj(&obj_stem(stem), parts); + vec![obj.into_bytes(), mtl.into_bytes()] + } + "gltf" => vec![gltf(stem, parts).into_bytes()], + "usda" => vec![usda(stem, parts).into_bytes()], + _ => vec![super::zip::store_aligned(&[(format!("{stem}.usda"), usda(stem, parts).into_bytes())], 64)?], + }; + Ok(names.into_iter().zip(bytes).collect()) +} + +/// The distinct colors of `parts` in order, and each part's index among them. +fn materials(parts: &[SolidMesh]) -> (Vec<[f32; 4]>, Vec) { + let mut colors: Vec<[f32; 4]> = vec![]; + let index = parts + .iter() + .map(|p| { + colors.iter().position(|c| *c == p.color).unwrap_or_else(|| { + colors.push(p.color); + colors.len() - 1 + }) + }) + .collect(); + (colors, index) +} + +/// An sRGB channel (0..1) as linear light. +fn linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// A number as text: the shortest decimal that reads back as the same value. +fn num(v: f64) -> String { + format!("{v}") +} + +fn obj(stem: &str, parts: &[SolidMesh]) -> (String, String) { + let (colors, material) = materials(parts); + let mut o = String::new(); + let _ = writeln!(o, "mtllib {stem}.mtl"); + let _ = writeln!(o, "o {stem}"); + for p in parts { + for v in &p.mesh.positions { + let _ = writeln!(o, "v {} {} {}", num(v[0]), num(v[1]), num(v[2])); + } + } + for p in parts { + for n in &p.mesh.normals { + let _ = writeln!(o, "vn {} {} {}", num(n[0]), num(n[1]), num(n[2])); + } + } + let mut base = 1usize; + for (k, p) in parts.iter().enumerate() { + let _ = writeln!(o, "g Solid_{}", k + 1); + let _ = writeln!(o, "usemtl Material_{}", material.get(k).copied().unwrap_or(0) + 1); + for t in &p.mesh.triangles { + let [a, b, c] = t.map(|i| i as usize + base); + let _ = writeln!(o, "f {a}//{a} {b}//{b} {c}//{c}"); + } + base += p.mesh.positions.len(); + } + let mut m = String::new(); + for (k, c) in colors.iter().enumerate() { + let _ = writeln!(m, "newmtl Material_{}", k + 1); + let _ = writeln!(m, "Kd {} {} {}", num(f64::from(c[0])), num(f64::from(c[1])), num(f64::from(c[2]))); + let _ = writeln!(m, "d {}", num(f64::from(c[3]))); + let _ = writeln!(m, "illum 1"); + } + (o, m) +} + +fn gltf(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let mut buffer: Vec = vec![]; + let (mut views, mut accessors, mut primitives) = (vec![], vec![], vec![]); + let mut view = |buffer: &mut Vec, bytes: Vec, target: u32| -> usize { + views.push(json!({ "buffer": 0, "byteOffset": buffer.len(), "byteLength": bytes.len(), "target": target })); + buffer.extend(bytes); + views.len() - 1 + }; + for (k, p) in parts.iter().enumerate() { + let floats = |v: &[[f64; 3]], scale: f64| v.iter().flatten().flat_map(|x| ((x * scale) as f32).to_le_bytes()).collect::>(); + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let positions = view(&mut buffer, floats(&p.mesh.positions, METERS_PER_POINT), 34962); + let normals = view(&mut buffer, floats(&p.mesh.normals, 1.0), 34962); + let indices = view(&mut buffer, p.mesh.triangles.iter().flatten().flat_map(|i| i.to_le_bytes()).collect(), 34963); + let count = p.mesh.positions.len(); + let at = accessors.len(); + accessors.push(json!({ + "bufferView": positions, "componentType": 5126, "count": count, "type": "VEC3", + "min": lo.map(|v| (v * METERS_PER_POINT) as f32), "max": hi.map(|v| (v * METERS_PER_POINT) as f32), + })); + accessors.push(json!({ "bufferView": normals, "componentType": 5126, "count": count, "type": "VEC3" })); + accessors.push(json!({ "bufferView": indices, "componentType": 5125, "count": p.mesh.triangles.len() * 3, "type": "SCALAR" })); + primitives.push(json!({ + "attributes": { "POSITION": at, "NORMAL": at + 1 }, + "indices": at + 2, + "material": material.get(k).copied().unwrap_or(0), + })); + } + let materials: Vec = colors + .iter() + .enumerate() + .map(|(k, c)| { + let mut m = json!({ + "name": format!("Material_{}", k + 1), + "pbrMetallicRoughness": { + "baseColorFactor": [linear(c[0]), linear(c[1]), linear(c[2]), c[3]], + "metallicFactor": 0.0, + "roughnessFactor": 1.0, + }, + "doubleSided": true, + }); + if c[3] < 1.0 { + m["alphaMode"] = json!("BLEND"); + } + m + }) + .collect(); + let doc = json!({ + "asset": { "version": "2.0", "generator": "VectorCraft" }, + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [{ "name": stem, "mesh": 0 }], + "meshes": [{ "name": stem, "primitives": primitives }], + "materials": materials, + "buffers": [{ + "byteLength": buffer.len(), + "uri": format!("data:application/octet-stream;base64,{}", vectorcraft_format::base64_encode(&buffer)), + }], + "bufferViews": views, + "accessors": accessors, + }); + serde_json::to_string_pretty(&doc).unwrap_or_default() +} + +/// `name` as a USD prim name: letters, digits and `_`, not starting with a digit. +fn prim_name(name: &str) -> String { + let mut out: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect(); + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +fn usda(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let root = prim_name(stem); + let triple = |v: [f64; 3]| format!("({}, {}, {})", num(v[0]), num(v[1]), num(v[2])); + let list = |items: Vec| items.join(", "); + let mut u = String::new(); + let _ = writeln!(u, "#usda 1.0"); + let _ = writeln!(u, "(\n defaultPrim = \"{root}\"\n metersPerUnit = {}\n upAxis = \"Y\"\n)\n", METERS_PER_POINT); + let _ = writeln!(u, "def Xform \"{root}\"\n{{"); + for (k, p) in parts.iter().enumerate() { + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let m = material.get(k).copied().unwrap_or(0) + 1; + let _ = writeln!(u, " def Mesh \"Solid_{}\" (\n prepend apiSchemas = [\"MaterialBindingAPI\"]\n )\n {{", k + 1); + let _ = writeln!(u, " uniform bool doubleSided = 1"); + let _ = writeln!(u, " float3[] extent = [{}, {}]", triple(lo), triple(hi)); + let _ = writeln!(u, " int[] faceVertexCounts = [{}]", list(vec!["3".to_string(); p.mesh.triangles.len()])); + let _ = writeln!(u, " int[] faceVertexIndices = [{}]", list(p.mesh.triangles.iter().flatten().map(u32::to_string).collect())); + let _ = writeln!(u, " rel material:binding = "); + let _ = writeln!( + u, + " normal3f[] normals = [{}] (\n interpolation = \"vertex\"\n )", + list(p.mesh.normals.iter().map(|n| triple(*n)).collect()) + ); + let _ = writeln!(u, " point3f[] points = [{}]", list(p.mesh.positions.iter().map(|v| triple(*v)).collect())); + let c = p.color; + let _ = writeln!( + u, + " color3f[] primvars:displayColor = [({}, {}, {})] (\n interpolation = \"constant\"\n )", + linear(c[0]), + linear(c[1]), + linear(c[2]) + ); + let _ = writeln!(u, " float[] primvars:displayOpacity = [{}] (\n interpolation = \"constant\"\n )", c[3]); + let _ = writeln!(u, " uniform token subdivisionScheme = \"none\"\n }}\n"); + } + let _ = writeln!(u, " def Scope \"Materials\"\n {{"); + for (k, c) in colors.iter().enumerate() { + let m = k + 1; + let _ = writeln!(u, " def Material \"Material_{m}\"\n {{"); + let _ = writeln!(u, " token outputs:surface.connect = \n"); + let _ = writeln!(u, " def Shader \"Surface\"\n {{"); + let _ = writeln!(u, " uniform token info:id = \"UsdPreviewSurface\""); + let _ = writeln!(u, " color3f inputs:diffuseColor = ({}, {}, {})", linear(c[0]), linear(c[1]), linear(c[2])); + let _ = writeln!(u, " float inputs:opacity = {}", c[3]); + let _ = writeln!(u, " float inputs:roughness = 1"); + let _ = writeln!(u, " token outputs:surface\n }}\n }}"); + } + let _ = writeln!(u, " }}\n}}"); + u +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/fileio/mesh3d/tests.rs b/crates/engine/src/cmd/fileio/mesh3d/tests.rs new file mode 100644 index 000000000..7c467fafa --- /dev/null +++ b/crates/engine/src/cmd/fileio/mesh3d/tests.rs @@ -0,0 +1,130 @@ +use serde_json::Value; +use vectorcraft_render::effects::{Mesh, SolidMesh}; + +use super::*; + +/// A 10 × 10 square facing the viewer: two triangles, each with corners of its own. +fn square(color: [f32; 4]) -> SolidMesh { + let [a, b, c, d] = [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]]; + let mesh = Mesh { positions: vec![a, b, c, a, c, d], normals: vec![[0.0, 0.0, 1.0]; 6], triangles: vec![[0, 1, 2], [3, 4, 5]] }; + SolidMesh { mesh, color } +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 0.5]; + +fn text(files: &[(String, Vec)], name: &str) -> String { + let (_, bytes) = files.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no {name}")); + String::from_utf8(bytes.clone()).unwrap() +} + +#[test] +fn obj_numbers_corners_across_solids_and_names_its_materials() { + let files = mesh_files("Box", "obj", &[square(RED), square(BLUE), square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.obj", "Box.mtl"]); + let obj = text(&files, "Box.obj"); + assert!(obj.starts_with("mtllib Box.mtl\no Box\n")); + assert_eq!(obj.lines().filter(|l| l.starts_with("v ")).count(), 18); + assert_eq!(obj.lines().filter(|l| l.starts_with("vn ")).count(), 18); + // The third solid's last triangle uses corners 13 to 18; red solids share Material_1. + assert_eq!(obj.lines().rfind(|l| l.starts_with("f ")), Some("f 16//16 17//17 18//18")); + assert_eq!(obj.lines().filter(|l| l.starts_with("usemtl ")).collect::>(), ["usemtl Material_1", "usemtl Material_2", "usemtl Material_1"]); + let mtl = text(&files, "Box.mtl"); + assert_eq!(mtl.matches("newmtl").count(), 2); + assert!(mtl.contains("newmtl Material_2\nKd 0 0 1\nd 0.5\n"), "{mtl}"); +} + +#[test] +fn gltf_holds_its_buffer_and_every_accessor_fits_it() { + let files = mesh_files("Box", "gltf", &[square(RED), square(BLUE)]).unwrap(); + let g: Value = serde_json::from_str(&text(&files, "Box.gltf")).unwrap(); + assert_eq!(g["asset"]["version"], "2.0"); + let uri = g["buffers"][0]["uri"].as_str().unwrap(); + let data = vectorcraft_format::base64_decode(uri.strip_prefix("data:application/octet-stream;base64,").unwrap()).unwrap(); + assert_eq!(g["buffers"][0]["byteLength"].as_u64(), Some(data.len() as u64)); + for v in g["bufferViews"].as_array().unwrap() { + assert!(v["byteOffset"].as_u64().unwrap() + v["byteLength"].as_u64().unwrap() <= data.len() as u64); + assert_eq!(v["byteOffset"].as_u64().unwrap() % 4, 0); + } + let prims = g["meshes"][0]["primitives"].as_array().unwrap(); + assert_eq!(prims.len(), 2); + assert_eq!((prims[0]["material"].as_u64(), prims[1]["material"].as_u64()), (Some(0), Some(1))); + // Positions are meters: the 10 pt square is 10/72 inch across. + let pos = &g["accessors"][prims[0]["attributes"]["POSITION"].as_u64().unwrap() as usize]; + let width = pos["max"][0].as_f64().unwrap() - pos["min"][0].as_f64().unwrap(); + assert!((width - 10.0 * 0.0254 / 72.0).abs() < 1e-7, "{width}"); + let index = &g["accessors"][prims[0]["indices"].as_u64().unwrap() as usize]; + assert_eq!((index["componentType"].as_u64(), index["count"].as_u64()), (Some(5125), Some(6))); + // Translucent paint blends; colors are linear. + assert_eq!(g["materials"][1]["alphaMode"], "BLEND"); + assert!(g["materials"][0].get("alphaMode").is_none()); + assert_eq!(g["materials"][0]["doubleSided"], true); +} + +#[test] +fn usda_is_y_up_in_points_with_a_material_for_each_color() { + let files = mesh_files("My Box", "usda", &[square(RED), square([0.5, 0.5, 0.5, 1.0])]).unwrap(); + let u = text(&files, "My Box.usda"); + assert!(u.starts_with("#usda 1.0\n")); + assert!(u.contains("defaultPrim = \"My_Box\"") && u.contains("upAxis = \"Y\"") && u.contains("def Xform \"My_Box\""), "{u}"); + assert!(u.contains(&format!("metersPerUnit = {}", 0.0254 / 72.0))); + assert_eq!(u.matches("def Mesh").count(), 2); + assert_eq!(u.matches("def Material").count(), 2); + assert!(u.contains("int[] faceVertexCounts = [3, 3]")); + assert!(u.contains("rel material:binding = ")); + // sRGB 0.5 is about 0.214 linear. + assert!(u.contains("color3f inputs:diffuseColor = (0.21404"), "{u}"); + assert_eq!(u.matches('{').count(), u.matches('}').count()); +} + +#[test] +fn usdz_stores_the_layer_uncompressed_on_a_64_byte_boundary() { + let files = mesh_files("Box", "usdz", &[square(RED)]).unwrap(); + let (name, zip) = &files[0]; + assert_eq!(name, "Box.usdz"); + // The local header: 30 bytes, the name, then the extra field. + let u16_at = |at: usize| u16::from_le_bytes([zip[at], zip[at + 1]]) as usize; + assert_eq!(&zip[..4], &[0x50, 0x4b, 0x03, 0x04]); + assert_eq!(u16_at(8), 0, "stored, not deflated"); + let start = 30 + u16_at(26) + u16_at(28); + assert_eq!(start % 64, 0); + let layer = mesh_files("Box", "usda", &[square(RED)]).unwrap().remove(0).1; + assert_eq!(&zip[start..start + layer.len()], layer.as_slice()); + let archive = super::super::zip::Archive::open(zip, 8).unwrap(); + assert_eq!(archive.read("Box.usda", usize::MAX).unwrap(), Some(layer)); +} + +#[test] +fn an_obj_names_its_mtl_file_without_spaces_or_comment_marks() { + let files = mesh_files("Box #1", "obj", &[square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box__1.obj", "Box__1.mtl"]); + assert!(text(&files, "Box__1.obj").starts_with("mtllib Box__1.mtl\n")); + assert_eq!(mesh_file_names("Box #1", "obj").unwrap(), ["Box__1.obj", "Box__1.mtl"]); + assert_eq!(mesh_file_names("Box #1", "usda").unwrap(), ["Box #1.usda"]); +} + +#[test] +fn obj_and_usda_keep_coordinates_a_32_bit_float_would_merge() { + let mut part = square(RED); + part.mesh.positions[0] = [20_000_000.0, 0.0, 0.0]; + part.mesh.positions[1] = [20_000_001.0, 0.0, 0.0]; + let obj = text(&mesh_files("Box", "obj", std::slice::from_ref(&part)).unwrap(), "Box.obj"); + assert!(obj.contains("v 20000000 0 0\nv 20000001 0 0\n"), "{obj}"); + let usda = text(&mesh_files("Box", "usda", &[part]).unwrap(), "Box.usda"); + assert!(usda.contains("(20000001, 0, 0)")); +} + +#[test] +fn an_unknown_format_is_refused() { + assert!(mesh_files("Box", "fbx", &[square(RED)]).is_err()); + assert!(mesh_file_names("Box", "").is_err()); + assert_eq!(mesh_file_names("Box", "obj").unwrap(), ["Box.obj", "Box.mtl"]); +} + +#[test] +fn prim_names_are_usd_identifiers() { + assert_eq!(prim_name("Logo 2"), "Logo_2"); + assert_eq!(prim_name("3D"), "_3D"); + assert_eq!(prim_name(""), "_"); + assert_eq!(prim_name("é/x"), "__x"); +} diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 74cfced2b..4e2e9a85f 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -48,7 +48,7 @@ pub const OPTIONS: &[FormatOption] = &[ FormatOption { name: "pdfCompatible", ty: "boolean", - default: "false", + default: "true", description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)", }, ]; @@ -146,7 +146,7 @@ struct NativeOptions { precision: Option, include_linked: bool, embed_profiles: Option, - pdf_compatible: bool, + pdf_compatible: Option, } /// What [`save_options`] leaves to [`encode`] (rendering and encoding take time). @@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> so.profiles = embedded_profiles(doc); } so.check().map_err(|e| bad(cmd, e.to_string()))?; - Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible })) + let pdf = o.pdf_compatible.unwrap_or(true); + so.pdf_compatible = Some(pdf); + Ok((so, Extras { preview: o.preview, pdf })) } /// The `precision` option `v`: a whole number of decimal places, 0 to @@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp if extras.preview { so.preview = preview_png(&drawn)?; } - if extras.pdf { - // Every artboard as PDF pages (no editing data: the file is the native document). - so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0); + if extras.pdf && !drawn.artboards.is_empty() { + // Every artboard as PDF pages (no editing data: the file is the native document), dated by + // the document's File Info, so the same document and dates give the same bytes. + let at = drawn.metadata.modified.or(drawn.metadata.created); + so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0); } drop(drawn); let native = match full { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 785764db9..52e7489dd 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result Result { + options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time()) +} + +/// [`options`] modified at `now` (Unix seconds) instead of the time of writing. +fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option) -> Result { 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))?; - let now = vectorcraft_doc::metadata::output_time(); Ok(PdfOptions { settings: settings(cmd, p)?, artboards, @@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + encode_at(cmd, doc, p, None) +} + +/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; warnings.extend(drawn_warnings); warnings.extend(overflow_warning(cmd, &full, p)?); Ok((bytes, warnings)) @@ -294,7 +303,20 @@ pub(super) fn encode_carrying( native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, ) -> Result<(Vec, Vec)> { - let mut opts = options(cmd, doc, p)?; + encode_carrying_at(cmd, doc, p, None, native, private) +} + +/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of +/// writing. +fn encode_carrying_at( + cmd: &str, + doc: &Document, + p: &Value, + modified: Option, + native: impl FnOnce() -> Result>, + private: impl FnOnce() -> Result)>>, +) -> Result<(Vec, Vec)> { + let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?; diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 5d6d25f26..9539a81c7 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -1,6 +1,7 @@ //! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and //! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX -//! header's HDpi and VDpi, or JP2 capture and display resolution boxes. Place +//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF +//! resolution tags. Place //! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it //! as a `pHYs` chunk. @@ -26,6 +27,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { super::pcxread::resolution(bytes) } else if super::jp2read::is_jpeg2000(bytes) { jp2(bytes) + } else if super::heifread::is_heif(bytes) { + super::heifread::with_exif(bytes, tiff) } else { None }; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 0eefa45c0..c4db832d2 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { } } // None given: the ones the document was last saved with in this format. - let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + // Remember the effective choice, including the encoder's default, so another document's + // later save cannot change what this one writes. Invalid non-null values still reach validation. + if matches!(format.id, "vectorcraft" | "template") { + let pdf = match options.get("pdfCompatible") { + None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)), + _ => None, + }; + if let Some(pdf) = pdf { + options.insert("pdfCompatible".into(), json!(pdf)); + } + } // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); @@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool { } } +/// Native documents and templates share only their PDF choice. Other source formats use the +/// application's last native choice, keeping an AI file's separate PDF option out of it. +fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option { + st.filter(|d| matches!(d.format, "vectorcraft" | "template")) + .and_then(|d| d.save_options.get("pdfCompatible")) + .and_then(Value::as_bool) + .or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool)) +} + /// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) /// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is /// left out, and one that moved is named by its new number. When none of the artboards the save @@ -757,7 +777,8 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if let Some(pdf) = native_pdf_compatible { + s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); + } Ok(json!({ "path": path })) } @@ -1053,11 +1077,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result { .or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone())); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); + let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten(); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone(); + if name == "pdfCompatible" + && let Some(pdf) = native_pdf + { + o["value"] = json!(pdf); + } } } v["saveFormats"] = @@ -1072,7 +1102,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 201a4b479..803109943 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -21,8 +21,8 @@ use super::encode::single_artboard; 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_by, encode_with_warnings, merge, unique_file_names, - with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, + unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -670,7 +670,7 @@ fn find_preset(s: &Session, name: &str) -> Option { } /// `p` over the settings of its `exportPreset`, when it names one (its own keys win). -fn with_preset(s: &Session, p: &Value) -> Result { +pub(super) fn with_preset(s: &Session, p: &Value) -> Result { let Some(name) = str_param(p, "exportPreset") else { return Ok(p.clone()) }; let preset = find_preset(s, name).ok_or_else(|| bad(C, format!("no export preset `{name}` (see document.exportPresets.list)")))?; let mut merged = preset.settings; @@ -1184,7 +1184,7 @@ fn items( // the item folders are (`fold`). let safe = |stems: Vec| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold); let named = |pieces: Vec<(String, Arc, Option, Rect)>, fallback: &str| -> Vec { - let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); + let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); pieces .into_iter() .zip(stems) @@ -1210,6 +1210,9 @@ fn items( let mut pieces = vec![]; for id in ids { let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?; + if a.format_3d.is_some() { + return Err(bad(C, format!("asset {id} is a 3D asset: assets.export3D writes it"))); + } let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?; pieces.push((a.name.clone(), d, Some(0), r)); } @@ -1258,7 +1261,7 @@ fn items( if boards.is_empty() { return Err(bad(C, "the document has no artboard")); } - let stems = safe(artboard_file_names(doc, &boards)); + let stems = safe(artboard_file_names_keeping_dots(doc, &boards)); Ok(boards .into_iter() .zip(stems) diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index a11e4a4e8..56bfd0f7b 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -177,7 +177,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result 0, "the outlines are there: {r}"); } -/// An image too large to read from the editing data of a file whose PDF part can't be read is +/// An image whose data is cut short in the editing data of a file whose PDF part can't be read is /// left out, and both notes say so. #[test] fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() { @@ -729,7 +729,7 @@ fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() { let mut s = Session::new(); let e = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string(); let r = open(&mut s, name, &file, json!({})).unwrap(); - assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image too large to read from the editing data was left out"]), "{r}"); + assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image whose data is cut short was left out"]), "{r}"); } /// A copied file whose PDF part can't be read pastes the objects of its editing data when asked diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 298adeb11..849767683 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -63,6 +63,32 @@ fn export_writes_dxf_of_the_chosen_version() { assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1")); } +#[test] +fn the_cnc_preset_sets_the_options_that_are_not_given() { + let mut s = two_boards(); + let cnc = text(&s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap()); + assert_eq!(header(&cnc, "$ACADVER").as_deref(), Some("AC1015")); + assert_eq!(header(&cnc, "$INSUNITS").as_deref(), Some("4")); + // document.export takes it too, beside a PDF preset's name. + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc"})).unwrap(); + assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some("AC1015")); + let o = dxf::options(&json!({"dxfPreset": "CNC", "unit": "in"})).unwrap(); + assert_eq!( + (o.version, o.unit, o.colors, o.preserve, o.alter_paths, o.outline_text), + ( + vectorcraft_cad::DxfVersion::R2000, + vectorcraft_doc::Unit::Inches, + vectorcraft_cad::ColorDepth::Aci256, + vectorcraft_cad::Preserve::Editability, + false, + true + ) + ); + assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); + let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); + assert!(e.contains("custom or cnc"), "{e}"); +} + #[test] fn use_artboards_writes_one_drawing_per_artboard() { let mut s = two_boards(); diff --git a/crates/engine/src/cmd/fileio/tests_heifread.rs b/crates/engine/src/cmd/fileio/tests_heifread.rs new file mode 100644 index 000000000..9820164da --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_heifread.rs @@ -0,0 +1,620 @@ +//! HEIC and HEIF Open, Place, pixel, color and resolution regressions using original synthetic +//! fixtures (quadrants of four colors, lossy HEVC, so colors are compared within a tolerance). + +#[cfg(feature = "heif")] +use serde_json::json; + +use super::heifread::is_heif; +use super::*; + +const QUADRANTS: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants.heic"); +const ALPHA: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-alpha.heic"); +const TEN_BIT: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-10bit.heic"); +const GRID: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid.heic"); +const GRID16: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid16.heic"); +const PPI300: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-300ppi.heic"); +const ROTATED: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated.heic"); +const ROTATED_300X150: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated-300x150.heic"); +const TONES: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones.heic"); +const TONES_P3_NCLX: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-nclx.heic"); +const TONES_P3_ICC: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc.heic"); +const TONES_P3_ICC_ONLY: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc-only.heic"); + +const EVERY_FIXTURE: [&[u8]; 12] = + [QUADRANTS, ALPHA, TEN_BIT, GRID, GRID16, PPI300, ROTATED, ROTATED_300X150, TONES, TONES_P3_NCLX, TONES_P3_ICC, TONES_P3_ICC_ONLY]; + +/// Red, green, blue and white: the top-left, top-right, bottom-left and bottom-right quadrants. +#[cfg(feature = "heif")] +const COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255; 4]]; + +/// The centers of the four quadrants of a square image `side` pixels wide. +#[cfg(feature = "heif")] +fn centers(side: u32) -> [(u32, u32); 4] { + let (a, b) = (side / 4, side * 3 / 4); + [(a, a), (b, a), (a, b), (b, b)] +} + +#[cfg(feature = "heif")] +fn decoded(bytes: &[u8]) -> image::RgbaImage { + let raster = load::raster_image(bytes).unwrap(); + image::load_from_memory(&raster.blob.bytes).unwrap().to_rgba8() +} + +#[cfg(feature = "heif")] +fn close(got: [u8; 4], want: [u8; 4], tolerance: u8) -> bool { + got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= tolerance) +} + +#[cfg(feature = "heif")] +fn assert_quadrants(img: &image::RgbaImage, side: u32, what: &str) { + assert_eq!(img.dimensions(), (side, side), "{what}"); + for ((x, y), want) in centers(side).into_iter().zip(COLORS) { + let got = img.get_pixel(x, y).0; + assert!(close(got, want, 8), "{what} at ({x}, {y}): {got:?} != {want:?}"); + } +} + +#[cfg(feature = "heif")] +#[test] +fn quadrants_open_and_place_as_their_pixels() { + assert_quadrants(&decoded(QUADRANTS), 32, "quadrants.heic"); + for name in ["photo.heic", "photo.heif", "unknown.bin"] { + let mut s = Session::new(); + let encoded = vectorcraft_format::base64_encode(QUADRANTS); + let opened = s.execute("document.open", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(opened["format"], "heif", "{name}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(32.0, 32.0)); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + assert_quadrants(&image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), 32, name); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let placed = s.execute("file.place", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(placed["format"], "heif", "{name}"); + } + assert!(load::texture_image(QUADRANTS).is_ok()); + // The general brand alone, without `heic`. + let mut general = QUADRANTS.to_vec(); + assert_eq!(&QUADRANTS[8..28], b"heic\0\0\0\0mif1heicmiaf"); + general[8..12].copy_from_slice(b"mif1"); + general[20..24].copy_from_slice(b"miaf"); + assert_quadrants(&decoded(&general), 32, "mif1 only"); +} + +#[test] +fn signatures_and_every_extension() { + for bytes in EVERY_FIXTURE { + assert!(is_heif(bytes)); + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + } + for ext in ["heic", "heif"] { + assert_eq!(load::detect(&format!("a.{ext}"), b"invalid").map(|f| f.id), Some("heif")); + assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext) && UNASSOCIATED_EXTS.contains(&ext), "{ext}"); + } + // A brand only among the compatible ones counts, and so do the general brands alone. + assert!(is_heif(b"\0\0\0\x14ftypabcd\0\0\0\0heic")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heix")); + assert!(is_heif(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1miaf")); + // A 64-bit box size (ISO/IEC 14496-12 4.2). + assert!(is_heif(b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0")); + let mut extended = b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0".to_vec(); + extended.extend_from_slice(&QUADRANTS[QUADRANTS.windows(4).position(|w| w == b"meta").unwrap() - 4..]); + assert_eq!(load::detect("unknown.bin", &extended).map(|f| f.id), Some("heif")); + // AVIF, with its brand major or compatible, and brands heic-rs doesn't read. + assert!(!is_heif(b"\0\0\0\x18ftypavif\0\0\0\0mif1miaf")); + assert!(!is_heif(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf")); + assert!(!is_heif(b"\0\0\0\x1cftypheic\0\0\0\0mif1heicavis")); + assert!(!is_heif(b"\0\0\0\x18ftyphevx\0\0\0\0hevxiso8")); + assert!(!is_heif(b"\0\0\0\x18ftypisom\0\0\0\0isomavc1")); + // A box shorter than its brands, longer than the file, or of another type. + assert!(!is_heif(b"\0\0\0\x0cftypheic")); + assert!(!is_heif(b"\0\0\0\x40ftypheic\0\0\0\0mif1")); + assert!(!is_heif(b"\0\0\0\x14styphei\0\0\0\0\0heic")); + assert!(!is_heif(b"")); +} + +#[cfg(feature = "heif")] +#[test] +fn alpha_10_bit_and_grid_images_keep_their_pixels() { + let alpha = decoded(ALPHA); + assert_eq!(alpha.dimensions(), (32, 32)); + for ((x, y), want) in centers(32).into_iter().zip(COLORS) { + let got = alpha.get_pixel(x, y).0; + // The left half is opaque; the right half is transparent. + if x < 16 { + assert!(close(got, want, 8), "({x}, {y}): {got:?} != {want:?}"); + } else { + assert!(got[3] <= 8, "({x}, {y}): {got:?}"); + } + } + assert_quadrants(&decoded(TEN_BIT), 32, "10-bit"); + // Four 64 × 64 tiles, one for each quadrant. + assert_quadrants(&decoded(GRID), 128, "2 × 2 grid"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_of_cropped_tiles_is_refused() { + // 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each coded 64 × 64 and cropped back + // with its own clap, which heic-rs 0.1.4 leaves out when it composes the grid. + let error = load::raster_image(GRID16).err().unwrap().to_string(); + assert!(error.contains("kind of image that can't be read"), "{error}"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_without_its_own_color_box_uses_its_tiles() { + // The grid item's associations (ipma) list ispe (1), pixi (5) and colr (3); the tiles carry + // the same colr. Pointing the grid's third association at its ispe leaves the grid without one. + let mut tiles_only = GRID.to_vec(); + assert_eq!(&GRID[565..571], &[0, 1, 3, 1, 0x85, 3], "item 1 with three associations, the last colr"); + tiles_only[570] = 1; + assert_quadrants(&decoded(&tiles_only), 128, "colr on the tiles only"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_rotation_is_applied() { + // A 32 × 16 image, red then blue, stored with a quarter turn clockwise. + let img = decoded(ROTATED); + assert_eq!(img.dimensions(), (16, 32)); + assert!(close(img.get_pixel(8, 4).0, [255, 0, 0, 255], 8), "{:?}", img.get_pixel(8, 4)); + assert!(close(img.get_pixel(8, 27).0, [0, 0, 255, 255], 8), "{:?}", img.get_pixel(8, 27)); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED)})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(16.0, 32.0)); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_mirror_and_crop_are_applied() { + // The rotated image's irot (property 6) turned to no rotation, and to an imir of the same + // size: mirrored left to right (ISO/IEC 23008-12 6.5.12, axis 0). + assert_eq!(&ROTATED[384..393], b"\0\0\0\x09irot\x03"); + let mut stored = ROTATED.to_vec(); + stored[392] = 0; + let mut mirrored = ROTATED.to_vec(); + mirrored[388..393].copy_from_slice(b"imir\0"); + let (stored, mirrored) = (decoded(&stored), decoded(&mirrored)); + assert_eq!((stored.dimensions(), mirrored.dimensions()), ((32, 16), (32, 16))); + let red = [255, 0, 0, 255]; + assert!(close(stored.get_pixel(8, 8).0, red, 8) && !close(stored.get_pixel(24, 8).0, red, 8), "{:?}", stored.get_pixel(8, 8)); + for (x, y) in [(4, 4), (8, 8), (20, 12), (27, 3)] { + assert!(close(mirrored.get_pixel(x, y).0, stored.get_pixel(31 - x, y).0, 8), "({x}, {y})"); + } + // The quadrants' clap (property 4) takes the top-left 32 × 32 of the 64 × 64 coded picture. + // Rewritten to its top-left 16 × 16: a center offset of -24 (ISO/IEC 14496-12 12.1.4) leaves + // the red quadrant alone. Its edge next to green is left out, as lossy chroma blends there. + assert_eq!(&QUADRANTS[329..337], b"\0\0\0\x28clap"); + let mut cropped = QUADRANTS.to_vec(); + let clap: Vec = [16u32, 1, 16, 1, (-48i32).cast_unsigned(), 2, (-48i32).cast_unsigned(), 2].iter().flat_map(|v| v.to_be_bytes()).collect(); + cropped[337..369].copy_from_slice(&clap); + let cropped = decoded(&cropped); + assert_eq!(cropped.dimensions(), (16, 16)); + for (x, y) in [(0, 0), (8, 8), (12, 0), (0, 12), (12, 12)] { + assert!(close(cropped.get_pixel(x, y).0, red, 8), "({x}, {y}): {:?}", cropped.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn exif_resolution_sizes_the_image_and_72_ppi_without_it() { + let got = ppi::resolution(PPI300).unwrap(); + assert!((got.0 - 300.0).abs() < 0.01 && (got.1 - 300.0).abs() < 0.01, "{got:?}"); + assert_eq!(ppi::resolution(QUADRANTS), None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(PPI300)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + let stored = ppi::resolution(&blob.bytes).unwrap(); + assert!((stored.0 - 300.0).abs() < 0.01 && (stored.1 - 300.0).abs() < 0.01); + // 32 × 16 pixels at 300 ppi across and 150 down, turned a quarter: 16 × 32 pixels at 150 ppi + // across and 300 down, the same 7.68 × 7.68 pt either way. + assert_eq!(ppi::resolution(ROTATED_300X150), Some((300.0, 150.0)), "as stored"); + let img = load::raster_image(ROTATED_300X150).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (16, 32, Some((150.0, 300.0)))); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED_300X150)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_images_are_converted_to_srgb() { + use vectorcraft_color::cms::{DISPLAY_P3, Intent, icc::builtin_rgb}; + let p3 = builtin_rgb(DISPLAY_P3).unwrap(); + let plain = decoded(TONES); + // The ICC-only file's samples are full-range BT.601, which only its HEVC stream says. + for (what, bytes) in [("nclx", TONES_P3_NCLX), ("ICC", TONES_P3_ICC), ("ICC only", TONES_P3_ICC_ONLY)] { + let converted = decoded(bytes); + for (x, y) in centers(32) { + let [r, g, b, a] = plain.get_pixel(x, y).0; + let rgb = p3.to_srgb_into(&[r, g, b].map(|v| f32::from(v) / 255.0), Intent::RelativeColorimetric).unwrap(); + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + let got = converted.get_pixel(x, y).0; + assert!(close(got, [r, g, b, a], 2), "{what} at ({x}, {y}): {got:?} != {:?}", [r, g, b, a]); + } + } + // The orange quadrant changes, so the conversion ran. + assert!(!close(decoded(TONES_P3_NCLX).get_pixel(8, 8).0, plain.get_pixel(8, 8).0, 4)); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_named_by_the_stream_alone_is_converted_to_srgb() { + // The nclx file's item associates hvcC, colr (property 2), ispe, pixi and clap. Pointing the + // colr association at the ispe leaves the item without a color box, and its HEVC stream's + // video signal type, which names Display P3 with the sRGB curve, describes the samples. + let ipma = TONES_P3_NCLX.windows(4).position(|w| w == b"ipma").unwrap(); + let at = ipma + 4 + 4 + 4 + 2 + 1 + 1; + assert_eq!(&TONES_P3_NCLX[at - 4..at + 4], &[0, 1, 5, 0x81, 2, 3, 5, 0x84], "item 1 with five associations, the second colr"); + let mut stream_only = TONES_P3_NCLX.to_vec(); + stream_only[at] = 3; + let want = decoded(TONES_P3_NCLX); + let got = decoded(&stream_only); + for (x, y) in centers(32) { + assert!(close(got.get_pixel(x, y).0, want.get_pixel(x, y).0, 2), "at ({x}, {y}): {:?} != {:?}", got.get_pixel(x, y), want.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn two_quarter_turns_keep_the_resolution_axes() { + // The rotated image's ipma lists its irot once; a second association with the same irot turns + // it a half turn, so the EXIF resolution keeps its axes. + let ipma = ROTATED_300X150.windows(4).position(|w| w == b"ipma").unwrap(); + let count = ipma + 4 + 4 + 4 + 2; + let irot = ROTATED_300X150.windows(4).position(|w| w == b"irot").unwrap(); + let ipco = ROTATED_300X150.windows(4).position(|w| w == b"ipco").unwrap(); + // The irot's property index: its place among the boxes of ipco, counted from 1. + let mut index = 1u8; + let mut at = ipco + 4; + while at < irot - 4 { + index += 1; + at += u32::from_be_bytes(ROTATED_300X150[at..at + 4].try_into().unwrap()) as usize; + } + assert_eq!(at, irot - 4, "irot is a box of ipco"); + let n = usize::from(ROTATED_300X150[count]); + assert!(ROTATED_300X150[count + 1..count + 1 + n].contains(&(0x80 | index)), "the item's irot association, essential"); + let mut twice = ROTATED_300X150.to_vec(); + twice[count] += 1; + twice.insert(count + 1 + n, 0x80 | index); + // The ipma, iprp and meta boxes each grow by one byte, and the iloc offsets move with them. + let grow = |bytes: &mut Vec, name: &[u8; 4]| { + let at = bytes.windows(4).position(|w| w == name).unwrap() - 4; + let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) + 1; + bytes[at..at + 4].copy_from_slice(&size.to_be_bytes()); + }; + for name in [b"ipma", b"iprp", b"meta"] { + grow(&mut twice, name); + } + let iloc = twice.windows(4).position(|w| w == b"iloc").unwrap(); + assert_eq!(&twice[iloc + 4..iloc + 8], &[0, 0, 0, 0], "iloc version 0"); + let sizes = twice[iloc + 8]; + assert_eq!((sizes >> 4, sizes & 0xf), (4, 4), "4-byte offsets and lengths"); + assert_eq!(twice[iloc + 9] >> 4, 4, "4-byte base offsets"); + let items = u16::from_be_bytes([twice[iloc + 10], twice[iloc + 11]]); + // Each item: its id, data reference index and base offset, which moves, then its extents. + let mut at = iloc + 12; + for _ in 0..items { + at += 2 + 2; + let offset = u32::from_be_bytes(twice[at..at + 4].try_into().unwrap()) + 1; + twice[at..at + 4].copy_from_slice(&offset.to_be_bytes()); + at += 4; + let extents = u16::from_be_bytes([twice[at], twice[at + 1]]); + at += 2 + 8 * usize::from(extents); + } + let img = load::raster_image(&twice).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (32, 16, Some((300.0, 150.0))), "a half turn keeps the stored axes"); +} + +#[cfg(feature = "heif")] +#[test] +fn damaged_unsupported_and_oversized_files_are_refused() { + let error = |bytes: &[u8]| load::raster_image(bytes).err().unwrap().to_string(); + for cut in [16, 40, QUADRANTS.len() / 2, QUADRANTS.len() - 5] { + assert!(load::raster_image(&QUADRANTS[..cut]).is_err(), "{cut}"); + } + // An ftyp and nothing else, as an image sequence starts (its `moov` would follow). + assert!(error(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc").contains("kind of image that can't be read")); + assert!(error(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic\0\0\0\x08free").contains("HEIC or HEIF file")); + // The primary item's type (infe) turned from HEVC to AV1, with its hvcC kept, and with its + // hvcC turned into an av1C. + let mut av1 = QUADRANTS.to_vec(); + assert_eq!((&QUADRANTS[137..141], &QUADRANTS[176..180]), (&b"hvc1"[..], &b"hvcC"[..])); + av1[137..141].copy_from_slice(b"av01"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + av1[176..180].copy_from_slice(b"av1C"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + // The image spatial extents (`ispe`) claim 60,000 × 60,000 pixels. + let mut huge = QUADRANTS.to_vec(); + let at = huge.windows(4).position(|w| w == b"ispe").unwrap() + 8; + huge[at..at + 4].copy_from_slice(&60_000u32.to_be_bytes()); + huge[at + 4..at + 8].copy_from_slice(&60_000u32.to_be_bytes()); + assert!(error(&huge).contains("60000 × 60000 pixels is too large to draw"), "{}", error(&huge)); + // A single 64 × 64 coded picture (cropped to 32 × 32) takes its RGBA output and copy, + // 64 × 64 × 8, and its planes, 64 × 64 × 9: no composed frame is counted. + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17).is_ok()); + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17 - 1).unwrap_err().contains("too large to draw")); + // The same picture with an ispe of 32 × 32 (its crop's size): its stream still decodes + // 64 × 64, and that size is counted. + let mut small = QUADRANTS.to_vec(); + let at = small.windows(4).position(|w| w == b"ispe").unwrap() + 8; + assert_eq!(&small[at..at + 8], b"\0\0\0\x40\0\0\0\x40"); + small[at..at + 4].copy_from_slice(&32u32.to_be_bytes()); + small[at + 4..at + 8].copy_from_slice(&32u32.to_be_bytes()); + let refused = super::heifread::decode(&small, 64 * 64 * 17 - 1).map(|img| img.dimensions()); + assert!(refused.as_ref().is_err_and(|e| e.contains("too large to draw")), "{refused:?}"); + // A 128 × 128 grid of four 64 × 64 tiles adds its composed frame, 128 × 128 × 6. + let grid = 128 * 128 * (8 + 6) + 4 * 64 * 64 * 9; + assert!(super::heifread::decode(GRID, grid).is_ok()); + assert!(super::heifread::decode(GRID, grid - 1).unwrap_err().contains("too large to draw")); + // The grid's ispe (property 1) claims 16 × 16 pixels; its grid data still composes 128 × 128, + // and that size is counted. + let mut understated = GRID.to_vec(); + assert_eq!(&GRID[358..370], b"ispe\0\0\0\0\0\0\0\x80", "property 1 is the grid's 128 × 128 ispe"); + understated[366..370].copy_from_slice(&16u32.to_be_bytes()); + understated[370..374].copy_from_slice(&16u32.to_be_bytes()); + assert!(super::heifread::decode(&understated, grid - 1).unwrap_err().contains("too large to draw")); +} + +/// Appends `value` as `n` bits, most significant first. +#[cfg(feature = "heif")] +fn put(bits: &mut Vec, value: u64, n: u32) { + bits.extend((0..n).rev().map(|i| i < 64 && value >> i & 1 == 1)); +} + +/// Appends `value` as an unsigned Exp-Golomb code. +#[cfg(feature = "heif")] +fn put_ue(bits: &mut Vec, value: u64) { + let len = 64 - (value + 1).leading_zeros(); + put(bits, 0, len - 1); + put(bits, value + 1, len); +} + +/// The fields of SPS 0 of `width` × `height` 4:2:0 samples with `sub_layers` sub-layers, each +/// with its profile and level, up to its height. +#[cfg(feature = "heif")] +fn sps_head(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_head_with_id(width, height, sub_layers, 0) +} + +/// [`sps_head`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_head_with_id(width: u64, height: u64, sub_layers: u64, id: u64) -> Vec { + let mut bits = Vec::new(); + put(&mut bits, 0, 4); + put(&mut bits, sub_layers, 3); + put(&mut bits, 1, 1); + put(&mut bits, 0, 96); + for _ in 0..sub_layers { + put(&mut bits, 0b11, 2); + } + if sub_layers > 0 { + put(&mut bits, 0, 2 * (8 - sub_layers as u32)); + } + for _ in 0..sub_layers { + put(&mut bits, 0, 88 + 8); + } + put_ue(&mut bits, id); + put_ue(&mut bits, 1); + put_ue(&mut bits, width); + put_ue(&mut bits, height); + bits +} + +/// An SPS NAL unit of `bits` (see [`nal`]). +#[cfg(feature = "heif")] +fn sps_nal(bits: Vec) -> Vec { + nal(33, bits) +} + +/// A NAL unit of type `kind` holding `bits`, with its stop bit, and emulation prevention bytes +/// inserted as a stream carries them. +#[cfg(feature = "heif")] +fn nal(kind: u8, mut bits: Vec) -> Vec { + put(&mut bits, 1, 1); + while !bits.len().is_multiple_of(8) { + bits.push(false); + } + let rbsp: Vec = bits.chunks(8).map(|b| b.iter().fold(0u8, |v, &bit| v << 1 | u8::from(bit))).collect(); + let mut nal = vec![kind << 1, 1]; + let mut zeros = 0; + for b in rbsp { + if zeros >= 2 && b <= 3 { + nal.push(3); + zeros = 0; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + nal.push(b); + } + nal +} + +#[cfg(feature = "heif")] +fn sps(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_nal(sps_head(width, height, sub_layers)) +} + +/// A 64 × 64 SPS that uses every optional part before the VUI's video signal type: a conformance +/// window, sub-layer ordering for two sub-layers, explicit scaling lists, PCM, a reference picture +/// set predicted from another and a long-term picture, then an Extended_SAR aspect ratio and +/// overscan information. `signal` is the full-range flag and the color description, if any. +#[cfg(feature = "heif")] +fn sps_with_vui(signal: Option<(u64, Option<[u64; 3]>)>) -> Vec { + sps_with_vui_and_id(signal, 0) +} + +/// [`sps_with_vui`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_with_vui_and_id(signal: Option<(u64, Option<[u64; 3]>)>, id: u64) -> Vec { + let mut bits = sps_head_with_id(64, 64, 1, id); + put(&mut bits, 1, 1); + for offset in [0, 2, 0, 2] { + put_ue(&mut bits, offset); + } + put_ue(&mut bits, 0); + put_ue(&mut bits, 0); + // 8 bits of picture order count. + put_ue(&mut bits, 4); + put(&mut bits, 1, 1); + for _ in 0..2 * 3 { + put_ue(&mut bits, 1); + } + for value in [0, 3, 0, 3, 1, 1] { + put_ue(&mut bits, value); + } + // Scaling lists: matrix 1 of each size given explicitly, the others copied. + put(&mut bits, 0b11, 2); + for size in 0..4 { + for matrix in 0..if size == 3 { 2 } else { 6 } { + if matrix == 1 { + put(&mut bits, 1, 1); + if size > 1 { + put_ue(&mut bits, 5); + } + for _ in 0..if size == 0 { 16 } else { 64 } { + put_ue(&mut bits, 3); + } + } else { + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + } + } + } + // AMP off, SAO on, PCM with its depths, block sizes and loop filter flag. + put(&mut bits, 0b01, 2); + put(&mut bits, 1, 1); + put(&mut bits, 0x77, 8); + put_ue(&mut bits, 0); + put_ue(&mut bits, 1); + put(&mut bits, 0, 1); + // Two short-term sets: one negative and one positive picture, then a set predicted from it + // with three entries (unused with use_delta_flag, used, unused). + put_ue(&mut bits, 2); + put_ue(&mut bits, 1); + put_ue(&mut bits, 1); + for _ in 0..2 { + put_ue(&mut bits, 0); + put(&mut bits, 1, 1); + } + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + put(&mut bits, 0b01100, 5); + // One long-term picture: 8 bits of picture order count and its used flag. + put(&mut bits, 1, 1); + put_ue(&mut bits, 1); + put(&mut bits, 0xab, 8); + put(&mut bits, 1, 1); + put(&mut bits, 0b11, 2); + // VUI: Extended_SAR 1:1 and overscan information. + put(&mut bits, 1, 1); + put(&mut bits, 1, 1); + put(&mut bits, 255, 8); + put(&mut bits, 0x0001_0001, 32); + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + match signal { + Some((full, description)) => { + put(&mut bits, 1, 1); + put(&mut bits, 5, 3); + put(&mut bits, full, 1); + put(&mut bits, u64::from(description.is_some()), 1); + for code in description.into_iter().flatten() { + put(&mut bits, code, 8); + } + } + None => put(&mut bits, 0, 1), + } + // No chroma location, field or display window information, timing or restrictions. + put(&mut bits, 0, 8); + sps_nal(bits) +} + +#[cfg(feature = "heif")] +#[test] +fn picture_sizes_come_from_the_sequence_parameter_sets() { + use super::heifread::{picture_bytes, sps_size}; + for sub_layers in [0, 1, 6] { + for (w, h) in [(32, 32), (4032, 3024), (65535, 65535)] { + let nal = sps(w, h, sub_layers); + assert!(nal.windows(3).any(|w| w == [0, 0, 3]), "the profile's zeros need emulation prevention"); + assert_eq!(sps_size(&nal), Some((w, h, 1)), "{w} × {h}, {sub_layers} sub-layers"); + } + } + let mut vps = sps(32, 32, 0); + vps[0] = 32 << 1; + assert_eq!(sps_size(&vps), None); + assert_eq!(sps_size(&sps(32, 32, 0)[..8]), None); + // 4:2:0 is 3 bytes a sample position in each of three sets of planes, in 64-sample blocks. + assert_eq!(picture_bytes(QUADRANTS), Some(64 * 64 * 9)); + assert_eq!(picture_bytes(GRID), Some(4 * 64 * 64 * 9)); + assert!(picture_bytes(ALPHA).unwrap() > 64 * 64 * 9, "the alpha image is counted"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_color_description_comes_from_the_streams_video_signal_type() { + use super::heifread::{sps_signal, sps_size}; + use heic_rs::{MatrixCoefficients, Range}; + let signal = |nal: &[u8]| sps_signal(nal).map(|n| (n.primaries, n.transfer, n.matrix, n.matrix_code, n.range)); + let described = sps_with_vui(Some((1, Some([12, 13, 6])))); + assert_eq!(sps_size(&described), Some((64, 64, 1))); + assert_eq!(signal(&described), Some((12, 13, MatrixCoefficients::Bt601, 6, Range::Full))); + assert_eq!(signal(&sps_with_vui(Some((0, None)))), Some((2, 2, MatrixCoefficients::Unspecified, 2, Range::Limited))); + assert_eq!(signal(&sps_with_vui(None)), None); + assert_eq!(signal(&sps(64, 64, 0)), None, "cut short before its VUI"); + // The ICC-only fixture's stream: heif-enc writes full-range BT.601. + let ctx = heic_rs::context::Context::open(TONES_P3_ICC_ONLY).unwrap(); + let props = ctx.props(ctx.meta.primary).unwrap(); + assert!(props.nclx.is_none() && props.icc.is_some()); + let sets = props.hvcc.unwrap().parameter_sets(); + let nal = sets.into_iter().find(|nal| nal[0] >> 1 & 0x3f == 33).unwrap(); + assert_eq!(sps_signal(nal).map(|n| (n.matrix, n.range)), Some((MatrixCoefficients::Bt601, Range::Full))); +} + +#[cfg(feature = "heif")] +#[test] +fn the_slices_picture_parameter_set_picks_the_sps() { + use super::heifread::{active_sps, sps_signal}; + use heic_rs::Range; + let full = sps_with_vui_and_id(Some((1, Some([1, 13, 6]))), 0); + let limited = sps_with_vui_and_id(Some((0, Some([1, 13, 1]))), 1); + let pps = |id: u64, sps: u64| { + let mut bits = Vec::new(); + put_ue(&mut bits, id); + put_ue(&mut bits, sps); + nal(34, bits) + }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag for an IDR picture (type 19), + // slice_pic_parameter_set_id. + let slice = |kind: u8, pps: u64| { + let mut bits = vec![true]; + if kind == 19 { + bits.push(false); + } + put_ue(&mut bits, pps); + nal(kind, bits) + }; + let (pps0, pps3) = (pps(0, 0), pps(3, 1)); + let sets = [full.as_slice(), limited.as_slice(), pps0.as_slice(), pps3.as_slice()]; + let (idr3, trail0, idr5) = (slice(19, 3), slice(1, 0), slice(19, 5)); + assert_eq!(active_sps(&sets, &[&idr3]), Some(limited.as_slice())); + assert_eq!(active_sps(&sets, &[&trail0]), Some(full.as_slice())); + assert_eq!(active_sps(&sets, &[&idr5]), None, "no PPS 5"); + assert_eq!(active_sps(&sets, &[&pps0]), None, "no slice"); + assert_eq!(active_sps(&sets, &[&idr3]).and_then(sps_signal).map(|n| (n.matrix_code, n.range)), Some((1, Range::Limited))); +} + +#[cfg(not(feature = "heif"))] +#[test] +fn a_build_without_the_decoder_refuses_heif_files_with_a_message() { + for bytes in EVERY_FIXTURE { + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + let error = load::raster_image(bytes).err().unwrap().to_string(); + assert!(error.contains("can't be opened in this build"), "{error}"); + } + assert_eq!(ppi::resolution(PPI300), None); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index a38e35e82..4a6f67143 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -932,25 +932,31 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() { } let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": 7})).unwrap(); let legacy = load::load_with("art.ai", &b64(&r), &LoadOptions::default()).unwrap(); - // Each gradient the art paints with, by its kind and its stops' places and colors to 5% (version - // 7 writes a gradient stroke as its filled outline, so the objects differ, and a spot color's - // tint through its CMYK equivalent, 2% off here). + // Each gradient the art paints with, by its kind and its stops' places, and its stops' colors + // within 5% (version 7 writes a gradient stroke as its filled outline, so the objects differ), + // except a spot color's tint, which version 7 writes through its CMYK equivalent: 70% of that + // equivalent displays about 7% off the tint of the spot color. let gradients = |doc: &vectorcraft_doc::Document| { - let mut all = std::collections::BTreeSet::new(); + let mut all = std::collections::BTreeMap::new(); doc.walk(|node| { for it in node.appearance.items.iter() { if let vectorcraft_color::Paint::Gradient(g) = it.paint() { - let stops: Vec = g - .gradient - .stops - .iter() - .map(|s| format!("{:.3} {:?}", s.offset, s.color.to_rgb().map(|v| (v * 20.0).round() * 5.0))) - .collect(); - all.insert(format!("{:?} {stops:?}", g.gradient.kind)); + let places: Vec = g.gradient.stops.iter().map(|s| format!("{:.3}", s.offset)).collect(); + let colors: Vec<([f32; 3], bool)> = + g.gradient.stops.iter().map(|s| (s.color.to_rgb(), s.swatch.is_some() && s.tint < 1.0)).collect(); + all.insert(format!("{:?} {places:?}", g.gradient.kind), colors); } } }); all }; - assert_eq!(gradients(&legacy.doc), gradients(&before)); + let (got, want) = (gradients(&legacy.doc), gradients(&before)); + assert_eq!(got.keys().collect::>(), want.keys().collect::>()); + for (key, stops) in &want { + for (i, (rgb, spot)) in stops.iter().enumerate() { + let back = got[key][i].0; + let most = if *spot { 0.1 } else { 0.05 }; + assert!((0..3).all(|c| (back[c] - rgb[c]).abs() < most), "{key} stop {i}: {back:?} for {rgb:?}"); + } + } } diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 64dd14dcf..b0dcd368e 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -148,6 +148,19 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() { assert_eq!(r["name"], "nul-.zip"); } +/// A dot in an artboard's name stays a dot (`example.artboard.png`, #6143); a name of only dots, or +/// ending in one, still can't name another folder or end in a period, a leading dot is a dash (no +/// hidden `.draft.png`), and `/` and `:` are dashes. +#[test] +fn an_artboard_name_keeps_its_dots() { + let mut s = session(7); + for (index, name) in ["example.artboard", "v1.2.final", "..", "a/b:c", "trail.", ".draft", "..x.y"].iter().enumerate() { + s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap(); + } + let r = export(&mut s, json!({"formats": [{"format": "png"}]})); + assert_eq!(names(&r), ["example.artboard.png", "v1.2.final.png", "Artboard-3.png", "a-b-c.png", "trail.png", "-draft.png", "--x.y.png"]); +} + /// `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 diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 73ee604a9..eb3989597 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -224,7 +224,11 @@ fn save_writes_svg_and_remembers_its_options() { let native = dir.join("doc.vectorcraft").to_string_lossy().to_string(); s.execute("document.save", &json!({"path": native})).unwrap(); assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap())); - assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options"); + assert_eq!( + Value::Object(s.doc().unwrap().save_options.clone()), + json!({"pdfCompatible":true}), + "a native save retains its PDF choice without SVG options" + ); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/cmd/fileio/zip.rs b/crates/engine/src/cmd/fileio/zip.rs index 8279b368f..047fcb56e 100644 --- a/crates/engine/src/cmd/fileio/zip.rs +++ b/crates/engine/src/cmd/fileio/zip.rs @@ -23,6 +23,13 @@ pub const DEFLATE_CHUNK: usize = 1 << 20; /// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), stored without /// compression. Fails past the limits of a ZIP without its 64-bit extension (65534 files, 4 GB; 65535 is the ZIP64 marker). pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, String> { + store_aligned(files, 1) +} + +/// [`store`], with each file's data starting at a multiple of `align` bytes from the start of the +/// archive (USDZ asks for 64): a padding block in the local header's extra field (header id +/// 0x1986) moves it there. +pub fn store_aligned, B: AsRef<[u8]>>(files: &[(N, B)], align: usize) -> Result, String> { let too_big = || "too much to zip: at most 65534 files and 4 GB".to_string(); let count = u16::try_from(files.len()).ok().filter(|&c| c != u16::MAX).ok_or_else(too_big)?; let mut out = Vec::with_capacity(files.iter().map(|(n, b)| 76 + 2 * n.as_ref().len() + b.as_ref().len()).sum::() + 22); @@ -47,8 +54,21 @@ pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); out.extend_from_slice(&VERSION.to_le_bytes()); out.extend_from_slice(&shared); - out.extend_from_slice(&0u16.to_le_bytes()); // extra field length + // The data would start after the header (30 bytes) and the name; a padding block of at + // least its own 4-byte header moves it to the boundary. + let start = out.len() + 2 + name.len(); + let mut pad = if align > 1 { (align - start % align) % align } else { 0 }; + if pad > 0 && pad < 4 { + pad += align; + } + let pad_len = u16::try_from(pad).map_err(|_| format!("cannot align zip entries to {align} bytes"))?; + out.extend_from_slice(&pad_len.to_le_bytes()); // extra field length out.extend_from_slice(name); + if pad > 0 { + out.extend_from_slice(&0x1986u16.to_le_bytes()); + out.extend_from_slice(&(pad_len - 4).to_le_bytes()); + out.resize(out.len() + pad - 4, 0); + } out.extend_from_slice(bytes); // Central directory record. central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index cadbd6509..309347985 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Missing Fonts", [], None, - "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", + "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", has_doc, missing ), @@ -103,8 +103,8 @@ pub struct MissingFont { pub resolved: Option<(String, String)>, } -/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named -/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` +/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those +/// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` /// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out. pub fn missing_fonts(d: &Document) -> Vec { let db = FontDb::global(); diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index efe978628..2a6d1cb0d 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -10,8 +10,9 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; use vectorcraft_geom::Rect; +use super::select::on_artboard; use super::typecmd::refresh_bounds; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param}; pub fn specs() -> Vec { vec![ @@ -20,7 +21,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", + "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", has_doc, fonts ), @@ -29,7 +30,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", has_doc, replace ), @@ -38,7 +39,7 @@ pub fn specs() -> Vec { "Select Text by Font", [], None, - "{family, style?} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}", + "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", has_doc, select_font ), @@ -207,7 +208,18 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String { } } -/// The fonts (family, style, version) of the type in the layers and symbols of `d`. +/// The fonts (family, style, version) run `r` is drawn in: its own, or, set in a composite font, +/// the fonts of the parts its characters fall in ([`vectorcraft_text::FontDb::composite_fonts_for`]). +pub(super) fn run_fonts(r: &TextRun) -> Vec { + let parts = if r.inline.is_some() { None } else { vectorcraft_text::FontDb::global().composite_fonts_for(&r.style.font_family, &r.text) }; + match parts { + Some(parts) => parts.into_iter().map(|(family, style)| (family, style, None)).collect(), + None => vec![font_of(&r.style)], + } +} + +/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a +/// composite font's parts in place of its name ([`run_fonts`]). pub(super) fn used_fonts(d: &Document) -> BTreeSet { art_fonts(d, &[]) } @@ -218,7 +230,7 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { if let NodeKind::Text(t) = &n.kind { - fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()))); + fonts.extend(t.runs.iter().flat_map(run_fonts)); } }; d.walk(&mut add); @@ -343,7 +355,7 @@ impl Printed<'_> { self.overflow.insert(n.id); } for r in runs { - self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())); + self.fonts.extend(run_fonts(r)); // Inline graphics draw their symbols' art. if let Some(art) = &r.inline { self.symbol(&art.symbol); @@ -545,14 +557,15 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None }; let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect(); texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t)); - let mut found: BTreeMap = BTreeMap::new(); + // Each font's runs, objects and the text of its runs. + let mut found: BTreeMap)> = BTreeMap::new(); for t in texts { let mut seen = BTreeSet::new(); for r in &t.runs { let font = (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()); let e = found.entry(font.clone()).or_default(); e.0 += 1; - e.2.push_str(&r.text); + e.2.push(&r.text); if seen.insert(font) { e.1 += 1; } @@ -560,14 +573,27 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style, version), (runs, objects, chars))| { + .map(|((family, style, version), (runs, objects, texts))| { // The face the type is set in: the version it names when that one is installed. - let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); - let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); - let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); - lacking.sort_unstable(); - lacking.dedup(); - lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + let mut resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); + let (status, lacking) = match db.composite_coverage(&family, &texts) { + // A composite font: each character is drawn by the font of the part it falls in, and + // the row names the face of the first part that isn't there as named. + Some(c) => { + if c.status != vectorcraft_text::FontMatch::Exact { + resolved = c.face.map(|f| (f, c.status)); + } + (c.status, c.lacking) + } + None => { + let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); + let mut lacking: Vec = texts.concat().chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); + lacking.sort_unstable(); + lacking.dedup(); + lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + (status, lacking) + } + }; let mut row = json!({ "family": family, "style": style, "runs": runs, "objects": objects, "missing": status == vectorcraft_text::FontMatch::Missing, @@ -611,10 +637,11 @@ fn replace(s: &mut Session, p: &Value) -> Result { let (ff, fs) = font_param(p, "from", C)?; let (tf, ts) = font_param(p, "to", C)?; let db = vectorcraft_text::FontDb::global(); - // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans). - let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) { - Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, format!("font family `{tf}` is not available"))), + // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans), and a + // composite font as its own name. + let composite = db.composite_font(&tf).is_some(); + let Some(tf) = db.stored_family(&tf, ts.as_deref().unwrap_or("Regular")) else { + return Err(bad(C, format!("font family `{tf}` is not available"))); }; let matches = |st: &vectorcraft_doc::CharStyle| { st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)) @@ -625,8 +652,10 @@ fn replace(s: &mut Session, p: &Value) -> Result { if !matches(st) { return None; } - // An explicit style, else the closest the new family has to the old one. - let style = ts.clone().or_else(|| db.face(&tf, &st.font_style).map(|f| f.style.clone())).unwrap_or_else(|| st.font_style.clone()); + // An explicit style, else the closest the new family has to the old one (a composite font's + // parts name their own styles: the run keeps its style). + let closest = || if composite { None } else { db.face(&tf, &st.font_style).map(|f| f.style.clone()) }; + let style = ts.clone().or_else(closest).unwrap_or_else(|| st.font_style.clone()); (st.font_family != tf || st.font_style != style || st.font_version.is_some() || !st.font_axes.is_empty()).then_some(style) }; // Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked @@ -673,7 +702,7 @@ fn replace(s: &mut Session, p: &Value) -> Result { // Another font: none of the old one's versions or Variable Font settings. r.style.font_version = None; r.style.font_axes.clear(); - set.insert(font_of(&r.style)); + set.extend(run_fonts(r)); changed = true; n += 1; } @@ -689,7 +718,6 @@ fn replace(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in); - drop(replace_in); Ok(n) })? }; @@ -714,6 +742,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { let style = str_param(p, "style").map(str::to_string); let ids = scope(s, false, None)?; let d = &s.doc()?.doc; + let within = FontScope::read(d, p)?; // Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group // or layer, or on a locked artboard. let editable = d.editable(); @@ -759,11 +788,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result { }); } drop(editable); + let symbols = d.symbol_boxes(); + hits.retain(|id| within.holds(d, &symbols, *id)); + drop(symbols); let n = hits.len(); s.select(|_, sel| sel.set(hits))?; Ok(json!({ "count": n })) } +/// Where Select Text by Font looks: its `scope`. +enum FontScope { + Document, + /// The artboards' rectangles. + Artboards(Vec), + /// The layers and sublayers, with what they hold. + Layers(BTreeSet), +} + +impl FontScope { + fn read(d: &Document, p: &Value) -> Result { + const C: &str = "select.font"; + let expected = || bad(C, "`scope` must be \"document\", {artboards: [index, …]} or {layers: [id, …]}"); + let scope = match p.get("scope") { + None | Some(Value::Null) => return Ok(Self::Document), + Some(Value::String(v)) if v == "document" => return Ok(Self::Document), + Some(Value::Object(m)) if m.len() == 1 => m, + Some(_) => return Err(expected()), + }; + let list = |key: &str| -> Result>> { + match scope.get(key) { + None => Ok(None), + Some(Value::Array(a)) if !a.is_empty() => Ok(Some(a)), + Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))), + } + }; + if let Some(a) = list("artboards")? { + let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect); + return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::>().map(Self::Artboards); + } + if let Some(a) = list("layers")? { + let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer)); + return a + .iter() + .map(|v| layer(v).ok_or_else(|| bad(C, format!("{v} names no layer or sublayer")))) + .collect::>() + .map(Self::Layers); + } + Err(expected()) + } + + /// Is object `id` in the scope. + fn holds(&self, d: &Document, symbols: &vectorcraft_doc::SymbolBoxes, id: NodeId) -> bool { + match self { + Self::Document => true, + Self::Artboards(rects) => d.node(id).is_some_and(|n| rects.iter().any(|r| on_artboard(n, symbols, *r))), + Self::Layers(layers) => d.ancestry(id).is_some_and(|a| a.iter().any(|i| layers.contains(i))), + } + } +} + fn font_list(_: &mut Session, p: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); match str_param(p, "family") { @@ -912,6 +995,95 @@ mod tests { assert!(s.doc().unwrap().selection.objects.is_empty()); } + /// Select Text by Font with an artboard scope takes the text on the artboards, as All on + /// Active Artboard does: overlapping one, or centered on it, a straddling one included. + #[test] + fn select_font_in_artboards() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, x: i32| { + NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()) + }; + let first = text(&mut s, 10); + let straddling = text(&mut s, 180); + let second = text(&mut s, 350); + let third = text(&mut s, 650); + let _off = text(&mut s, 1000); + let select = |s: &mut Session, scope: Value| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap(); + s.doc().unwrap().selection.objects.clone() + }; + assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]); + assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]); + assert_eq!(select(&mut s, json!("document")).len(), 5); + // The same objects All on Active Artboard takes. + s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]); + // Hidden text stays out. + s.execute("select.set", &json!({"ids": [third.0]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]); + for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] { + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}"); + } + } + + /// Select Text by Font with a layer scope takes the text in the layers, their sublayers + /// included, and still leaves out hidden and locked text. + #[test] + fn select_font_in_layers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, y: i32| { + NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()) + }; + let first_layer = s.doc().unwrap().doc.layers[0].id; + let a = text(&mut s, 20); + let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let b = text(&mut s, 40); + let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": sub})).unwrap(); + let in_sub = text(&mut s, 60); + let locked = text(&mut s, 80); + s.execute("select.set", &json!({"ids": [locked.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + let select = |s: &mut Session, layers: Value| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap(); + let mut v = s.doc().unwrap().selection.objects.clone(); + v.sort(); + v + }; + let d = &s.doc().unwrap().doc; + assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer"); + assert_eq!(select(&mut s, json!([first_layer.0])), [a]); + assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]); + assert_eq!(select(&mut s, json!([sub])), [in_sub]); + assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]); + // Not a layer: an error, the selection as it was. + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err()); + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err()); + assert_eq!(s.doc().unwrap().selection.objects.len(), 2); + } + + /// A symbol instance whose art uses the font is selected when the instance is in scope. + #[test] + fn select_font_scope_takes_symbol_instances_in_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0]; + let select = |s: &mut Session, i: u64| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone() + }; + assert_eq!(select(&mut s, 0), 1); + assert_eq!(s.doc().unwrap().selection.objects, [instance]); + assert_eq!(select(&mut s, 1), 0); + } + /// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked /// group or layer, and says so. Text on a visible, unlocked template layer is changed. #[test] diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 21b5245ae..0f72b066a 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -92,8 +92,12 @@ impl Session { /// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in /// document coordinates, and the length its spreads are fractions of there (see - /// [`Node::proxy_freeform`]). + /// [`Node::proxy_freeform`]). `None` while an object of a symbol instance is selected + /// ([`vectorcraft_doc::Selection::symbol_part`]): its points are in the symbol's coordinates. pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> { + if self.active()?.selection.symbol_part.is_some() { + return None; + } let (n, item) = self.proxy_source(stroke)?; n.proxy_freeform(stroke, item) } diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 2b0e214cc..4a42f03d4 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -354,8 +354,10 @@ fn close(a: f64, b: f64) -> bool { } /// The members a Global Edit started on the one selected object would take, with `o`: that -/// object first, then the others in paint order. +/// object first, then the others in paint order. Refused while an object of a dynamic symbol +/// instance's art is selected. fn matches(s: &Session, o: &Options) -> Result> { + super::edit::refuse_symbol_part(s)?; let st = s.doc()?; let d = &st.doc; let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) }; @@ -423,6 +425,7 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { if st.global_edit.is_some() { return Ok(()); } + super::edit::no_symbol_part(s)?; let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; @@ -765,10 +768,12 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se let mut placed = model.clone(); placed.transform(*xf * back, Scaling::default()); give(was, &model, placed, &mut n, d); + super::object::keep_pixel_aligned(&mut n, Scaling::default()); if let Some(slot) = d.node_mut(*id) { *slot = n; edited.push(*id); } + super::object::keep_compound_aligned(d, *id, Scaling::default()); } edited } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 47989a29c..c73a23d6c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Select Gradient Stop", [], None, - "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}", + "{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on; refused while an object of a symbol instance's art is selected → {index}", always, select_stop ), @@ -59,6 +59,10 @@ pub(crate) fn active_gradient(s: &Session) -> Option { fn select_stop(s: &mut Session, p: &Value) -> Result { const C: &str = "gradient.selectStop"; + // No document is needed for the default paint's gradient, so this does not use `refuse_symbol_part`. + if s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { + return Err(super::brushsym::part_only()); + } let index = match p.get("index") { None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")), Some(Value::Null) => None, diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 1ea621c63..c33be78d5 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -58,12 +58,16 @@ pub(super) fn create(s: &mut Session, p: &Value) -> Result { } /// The graph `id` names (or the graph containing it), else the selected graph (or the graph -/// containing the selection). +/// containing the selection). Refused while an object of a dynamic symbol instance's art is +/// selected. pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; let start = match named_id(p)? { Some(id) => vec![id], - None => st.selection.objects.clone(), + None => { + crate::cmd::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } }; for id in start { let mut cur = Some(id); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 8b059c111..5825c5fc0 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1316,6 +1316,195 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { assert_eq!(spec(&s, id), kept); } +/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless +/// `stroke`: the rectangle and the blue paint. +fn blue_style(s: &mut Session, name: &str, stroke: bool) -> (NodeId, Paint) { + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.setItem", &json!({"ids": [r.0], "index": top_row(s, r, true), "color": "#0000ff", "opacity": 50})).unwrap(); + if !stroke { + s.execute("appearance.removeItem", &json!({"ids": [r.0], "index": top_row(s, r, false)})).unwrap(); + } + s.execute("graphicStyle.new", &json!({"id": r.0, "name": name})).unwrap(); + let blue = s.doc().unwrap().doc.node(r).unwrap().appearance.fill_paint(); + (r, blue) +} + +/// Series 0's marks with a red 2 pt stroke stored from `marks[1]` and a 40% fill opacity stored from `marks[2]`. +fn stored_red_stroke() -> (Session, NodeId, Vec) { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], true), "opacity": 40})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke_width, kept.fill_opacity), (Some(2.0), Some(0.4))); + (s, id, marks) +} + +/// After the graph is drawn again, every mark of series 0 has `fill` at `opacity` and `stroke` at `weight`; two undos (the +/// data edit, then the command) bring back the paints the graph stored in `was`. +fn series_0_is(s: &mut Session, id: NodeId, (fill, opacity, stroke, weight): (&Paint, f32, &Paint, f64), was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 0).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((&f.paint, f.opacity), (fill, opacity)); + assert_eq!((&m.appearance.stroke_paint(), m.appearance.stroke_width()), (stroke, weight)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// A graphic style applied to a series mark is stored on the graph, its fill opacity included. A style without a stroke, +/// applied to a mark that shows one, leaves the series without a stroke. +#[test] +fn a_graphic_style_applied_to_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the style and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// A graphic style added on top of a series mark's appearance (Alt-click) stores the kinds of row the style shows: a style +/// with only a fill leaves the stroke the series stores. +#[test] +fn a_graphic_style_added_to_a_series_mark_keeps_only_its_own_kinds() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// A graphic style whose stroke row is hidden, added on top of a series mark (Alt-click), stores its fill and leaves the +/// stroke the series stores, although the mark shows a stroke of its own. +#[test] +fn a_graphic_style_with_a_hidden_stroke_added_to_a_series_mark_keeps_the_stored_stroke() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + let red = spec(&s, id).series_paints[0].stroke.clone().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + let (rect, blue) = blue_style(&mut s, "Blue", true); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, false), "visible": false})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Hidden stroke"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.apply", &json!({"name": "Hidden stroke", "ids": [marks[0].0], "add": true})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0))); + series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was); +} + +/// Redefine Graphic Style restyles the series marks still linked to the style, and the graph stores the new look. +#[test] +fn redefining_a_graphic_style_updates_a_linked_series_mark() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), green, "the mark follows the style"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green.clone()), None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + let (stroke, weight) = { + let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); + (r.appearance.stroke_paint(), r.appearance.stroke_width()) + }; + series_0_is(&mut s, id, (&green, 1.0, &stroke, weight), &was); +} + +/// Redefine Graphic Style made from a series mark does not edit that mark, so the graph keeps the paint another mark of the +/// series stored after it: the graph drawn again shows the same marks with or without the redefinition, and undo brings +/// back the stored paint. +#[test] +fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_stored() { + let (mut s, id, _, marks) = column_marks(); + blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#00ff00"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + assert_eq!(was[0].fill, Some(red.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + let without = marks_of(&s, id, 0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": marks[1].0})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "the source mark was not edited"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(marks_of(&s, id, 0), without); + assert!(without.iter().all(|m| m.0 == red)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. +#[test] +fn default_fill_and_stroke_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.default", &json!({"ids": [marks[0].0]})).unwrap(); + let (white, black) = (Paint::solid(vectorcraft_color::Color::WHITE), Paint::solid(vectorcraft_color::Color::BLACK)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(white.clone()), None)); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(black.clone()), Some(1.0))); + series_0_is(&mut s, id, (&white, 1.0, &black, 1.0), &was); +} + +/// Swap Fill and Stroke on a series mark stores the swapped paints and the stroke weight, and leaves the opacity the series +/// stores. +#[test] +fn swapping_a_series_marks_fill_and_stroke_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let (gray, green) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let was = spec(&s, id).series_paints; + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(green.clone()), Some(0.4))); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(gray.clone()), Some(3.0))); + series_0_is(&mut s, id, (&green, 0.4, &gray, 3.0), &was); +} + +/// Swap Fill and Stroke writes the topmost fill even when it is hidden: the visible fill under it does not replace the +/// fill the series stores. +#[test] +fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + let red = spec(&s, id).series_paints[0].fill.clone(); + assert_eq!(red, Some(s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint())); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap(); + s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index b19206c39..9841d2abb 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -76,8 +76,9 @@ pub fn specs() -> Vec { // ---------- predicates ---------- /// The Intertwine groups of the selection: selected ones, and those around a selected object. +/// None while an object of a dynamic symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine)); @@ -92,6 +93,7 @@ fn selected_groups(s: &Session) -> Vec { fn has_intertwine(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) } } @@ -378,11 +380,14 @@ fn arrange(s: &mut Session, p: &Value) -> Result { } id } - None => match selected_groups(s).as_slice() { - [g] => *g, - [] => return Err(bad(C, "select an Intertwine group")), - _ => return Err(bad(C, "select one Intertwine group")), - }, + None => { + super::edit::refuse_symbol_part(s)?; + match selected_groups(s).as_slice() { + [g] => *g, + [] => return Err(bad(C, "select an Intertwine group")), + _ => return Err(bad(C, "select one Intertwine group")), + } + } }; let at = match p.get("at") { Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?), diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index d4adaf2fd..cf7c9b6b2 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Rect}; use super::{ @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)", + "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -601,7 +601,7 @@ fn set_current(s: &mut Session, p: &Value) -> Result { fn highlight(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.highlight"; - let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = ids_param(p, "ids", C)?.ok_or_else(|| bad(C, "missing ids"))?; ids.truncate(MAX_ROWS); let mode = str_param(p, "mode").unwrap_or("set"); let st = s.doc_mut()?; @@ -740,7 +740,7 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; ids.truncate(MAX_ROWS); let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { @@ -869,6 +869,17 @@ fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Result> { + let bad_side = || bad(cmd, "`side` must be \"left\", \"right\", \"top\" or \"bottom\""); + match p.get("side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => ArtboardSide::parse(v).map(Some).ok_or_else(bad_side), + Some(_) => Err(bad_side()), + } +} + /// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within /// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`]. fn video_pixel_aspect(p: &Value, cmd: &str) -> Result> { @@ -886,10 +897,24 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let d0 = &s.doc()?.doc; let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1); - let r = rect_from(p, def); let color = artboard_color(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + let r = match artboard_side(p, "artboard.new")? { + Some(side) => { + if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { + return Err(bad("artboard.new", "`side` places the artboard: leave out x, y, width and height")); + } + let beside = match p.get("beside") { + None | Some(Value::Null) => d0.artboards.len().saturating_sub(1), + Some(_) => super::appearance::index_param(p, "beside", "artboard.new")?, + }; + let src = d0.artboards.get(beside).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + src + side.offset(src) + } + None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), + None => rect_from(p, def), + }; let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); @@ -1100,6 +1125,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { for id in &e.art { if let Some(n) = d.node_mut(*id) { n.transform(xf, sc); + super::object::keep_pixel_aligned(n, sc); } } } else if !e.resized { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b4f570582..48e2d1793 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -966,7 +966,7 @@ enum Item { /// The selected objects (or `ids`) as a library graphic (its id and name set by the caller). fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", cmd)? { Some(ids) => ids, None => st.selection.in_paint_order(&st.doc), }; @@ -1006,7 +1006,7 @@ fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result let given = NewColor { name: color.to_hex(), color, spot: None, tint: 1.0 }; return Ok(spot_color(s, given, swatch, tint)); } - match selected_paint(s, p, stroke)? { + match selected_paint(s, p, stroke, cmd)? { Paint::Solid { color, swatch, tint } => { let swatch = swatch.filter(|n| !n.is_empty()); let name = swatch.clone().unwrap_or_else(|| color.to_hex()); @@ -1029,9 +1029,9 @@ fn spot_color(s: &Session, c: NewColor, swatch: Option, tint: f32) -> Ne /// The first selected object's (or `ids`') fill (`stroke`: stroke) paint (type's: its first /// character's, as the proxy shows it), else the default one. -fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result { +fn selected_paint(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result { let st = s.doc()?; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", cmd)? { Some(ids) => ids, None => st.selection.objects.clone(), }; @@ -1071,7 +1071,7 @@ fn gradient_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result (None, selected_paint(s, p, stroke)?), + None => (None, selected_paint(s, p, stroke, cmd)?), }; let Paint::Gradient(mut g) = paint else { return Err(bad(cmd, if stroke { "the selection has no gradient stroke" } else { "the selection has no gradient fill" })); @@ -1094,7 +1094,7 @@ fn pattern_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result name.to_string(), _ => return Err(bad(cmd, format!("no pattern swatch `{name}`"))), }, - None => match selected_paint(s, p, stroke)? { + None => match selected_paint(s, p, stroke, cmd)? { Paint::Pattern { pattern, .. } => pattern, _ => return Err(bad(cmd, if stroke { "the selection has no pattern stroke" } else { "the selection has no pattern fill" })), }, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 89492ad01..22d61723e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -23,7 +23,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use serde_json::{Value, json}; @@ -219,6 +219,30 @@ struct Found { modified: Option, /// Read when telling whether it changed needed them. bytes: Option>, + /// `path` relative to the folder of [`LinkBase::relative`], when [`locate`] had that file. + relative: Option>, +} + +/// The files a document's links are looked for from: `doc`, whose folder a link's path is followed +/// from and its file is looked for by name in, and `relative`, the file its links' stored relative +/// paths name places from (the file the document was read from). +#[derive(Clone, Copy, Debug, Default)] +pub struct LinkBase<'a> { + pub doc: Option<&'a str>, + pub relative: Option<&'a str>, +} + +impl<'a> LinkBase<'a> { + /// Both the file at `path`. + pub fn file(path: Option<&'a str>) -> Self { + Self { doc: path, relative: path } + } + + /// The open document `st`'s. One with no file of its own looks beside the file it was read from. + pub(crate) fn of(st: &'a crate::DocState) -> Self { + let (path, from) = (st.path.as_deref(), st.links_from.as_deref()); + Self { doc: path.or(from), relative: from.or(path) } + } } impl Found { @@ -237,7 +261,17 @@ impl Found { /// [`Self::link`] with the bytes' hash already taken. fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo { - LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() } + LinkInfo { size: Some(self.size), modified: self.modified, hash: Some(hash), ..self.moved(base) } + } + + /// `base` pointing at this file. A file found away from `base`'s path takes its relative path + /// from where it was found. + fn moved(&self, base: &LinkInfo) -> LinkInfo { + let relative = match &self.relative { + Some(found) if self.path != base.path => found.clone(), + _ => base.relative.clone(), + }; + LinkInfo { path: self.path.clone(), relative, ..base.clone() } } } @@ -255,15 +289,41 @@ pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo { /// The file at `path` when there is one. fn found_at(path: String) -> Option { let (size, modified) = file_stamp(&path)?; - Some(Found { path, size, modified, bytes: None }) + Some(Found { path, size, modified, bytes: None, relative: None }) } -/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at -/// its relative path or by name in the document's folder. Only paths -/// [`fileio::document_path`] follows are looked at. -fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option { - let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())]; - stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at) +/// Where the file of `link` (of a document looking from `base`) is: at its path, else at its +/// relative path from the folder of [`LinkBase::relative`], then from the document's folder, else +/// by name in the document's folder, then in that of [`LinkBase::relative`]. Only paths +/// [`fileio::document_path`] follows from the document's own file are looked at. +fn locate(link: &LinkInfo, base: LinkBase) -> Option { + let (relative, other) = (link.relative.as_deref(), base.doc != base.relative); + let stored = [ + (Some(link.path.as_str()), base.doc), + (relative, base.relative), + (relative.filter(|_| other), base.doc), + (Some(link.name()), base.doc), + (other.then_some(link.name()), base.relative), + ]; + let mut f = stored.into_iter().filter_map(|(p, from)| follow_from(p?, from, base.doc, fileio::document_path)).find_map(found_at)?; + f.relative = base.relative.map(|from| { + let from = absolute_path(from); + Path::new(&from).parent().and_then(|dir| relative_path(Path::new(&f.path), dir)) + }); + Some(f) +} + +/// The place `stored` names from the file `from`, as `resolve` ([`fileio::document_path`]) follows +/// it, when it also follows that place from the document's own file `doc`: a network share the +/// document is no longer on is refused. +pub(crate) fn follow_from( + stored: &str, + from: Option<&str>, + doc: Option<&str>, + resolve: impl Fn(&str, Option<&str>) -> std::result::Result, +) -> Option { + let path = resolve(stored, from).ok()?; + if from == doc { Some(path) } else { resolve(&path, doc).ok() } } /// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it @@ -272,10 +332,10 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option) -> (Status, Option) { - let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) }; +fn probe(g: &Group, base: LinkBase) -> (Status, Option) { + let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) }; let l = &g.link; if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified { return (Status::Ok, Some(f)); @@ -328,9 +388,9 @@ pub(crate) struct LinkedFile { pub bytes: Option>, } -/// Every file the linked images of `d` (saved at `doc_path`) show, in the layers, symbols and +/// Every file the linked images of `d` (looking from `base`) show, in the layers, symbols and /// patterns, once each. -pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec { +pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec { let mut seen = std::collections::BTreeSet::new(); groups(d, None) .into_iter() @@ -338,7 +398,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec) -> Vec(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec) { +pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec) { let mut keys = BTreeSet::new(); d.visit_images(|_, im| { if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) { @@ -363,7 +423,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> let all = groups(d, None); for key in keys { let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue }; - let Some(mut f) = locate(&g.link, doc_path) else { + let Some(mut f) = locate(&g.link, base) else { previews.insert(key); continue; }; @@ -398,7 +458,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec)> { groups(&st.doc, ids) .into_iter() - .filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing)) + .filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, LinkBase::of(st)).0 == Status::Missing)) .map(|g| (g.link, g.ids)) .collect() } @@ -847,12 +907,13 @@ impl Resolved { } } -/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read -/// again (their images had the preview), modified ones are read again when `update`, links found -/// away from their path take the new path. A link nothing is known of whose image shows a preview -/// (a file another app wrote kept only that) was never read: its file is read now, into the -/// image's bounds, which isn't a change to report. Nothing here is an undo step. -pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { +/// The linked files of document `d`, just read and looking from `base`: those found unchanged are +/// read again (their images had the preview), modified ones are read again when `update`, links +/// found away from their path take the new path. A link nothing is known of whose image, or placed +/// document, shows a preview (a file another app wrote kept only that) was never read: its file is +/// read now, into the object's bounds, which isn't a change to report. A placed document whose +/// file's bytes are gone isn't showing a preview. Nothing here is an undo step. +pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); // The image (with its preview) and hash of each file read for a preview here, by path: images // of other keys linking to the same file share them, rather than each reading, decoding and @@ -862,10 +923,14 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // away from its path or read again. let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { - let row = rows(std::iter::once(&g), doc_path); - let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); + let row = rows(std::iter::once(&g), base.doc); + let shows_preview = match g.document { + None => previewing(d, &g.key), + Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy), + }; + let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview; let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d); - match probe(&g, doc_path) { + match probe(&g, base) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { let force = unread.then_some(Preserve::Bounds); @@ -889,7 +954,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p None => { let link = match f.bytes.take() { Some(b) => f.link(&b, &g.link), - None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + None => f.moved(&g.link), }; if link != g.link { set_links(d, &g.ids, &link); @@ -927,7 +992,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // Found away from its path, or read again: the link follows. let link = match &bytes { Some(b) => f.link(b, &g.link), - None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + None => f.moved(&g.link), }; if link != g.link { set_links(d, &g.ids, &link); @@ -1030,42 +1095,68 @@ fn relative_path(target: &Path, base: &Path) -> Option { Some(ups.chain(rest).collect::>>().join("/")) } -/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they -/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set. -pub fn with_relative_paths(d: &Document, dest: &str) -> Option { - let dest = absolute_path(dest); - let dir = Path::new(&dest).parent()?; - let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir); - let outdated = |l: &LinkInfo| relative(l) != l.relative; - let mut changed = false; - d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated)); - d.visit_placed(|_, p| changed |= outdated(&p.link)); - if !changed { - return None; +/// The file `link` (of a document looking from `base`) is written relative to: where [`locate`] +/// finds it, else the place its relative path names from the folder of [`LinkBase::relative`], +/// else its path. +fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf { + if let Some(f) = locate(link, base) { + return PathBuf::from(f.path); + } + match (base.relative, link.relative.as_deref()) { + (Some(_), Some(rel)) => fileio::document_path(rel, base.relative).map_or_else(|_| PathBuf::from(&link.path), PathBuf::from), + _ => PathBuf::from(&link.path), } - let mut d = d.clone(); - d.update_links_where(outdated, |l| l.relative = relative(l)); - Some(d) } -/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own, -/// to the path where that document finds its file, so the copy finds the same files. -/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set. -pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { - // The new path of each link by what `locate` reads of it: its path, relative path and name. - let mut found: BTreeMap<(String, Option), Option> = BTreeMap::new(); +/// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written +/// in; `None` when they are already. A link whose file is not at its path is written relative to +/// where its file is found, else to the place its relative path names. Links in the layers, +/// symbols, graph designs, patterns and opacity masks are all set. +pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option { + let dest = absolute_path(dest); + let dir = Path::new(&dest).parent()?; + // The relative path of each link by its path and stored relative path, each file looked at once. + let mut wanted: BTreeMap<(String, Option), Option> = BTreeMap::new(); let mut look = |l: &LinkInfo| { - found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path)); + wanted.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| relative_path(&link_target(l, base), dir)); }; d.visit_images(|_, im| im.link.iter().for_each(&mut look)); d.visit_placed(|_, p| look(&p.link)); - found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path)); + wanted.retain(|(_, stored), rel| rel != stored); + if wanted.is_empty() { + return None; + } + let key = |l: &LinkInfo| (l.path.clone(), l.relative.clone()); + let mut d = d.clone(); + d.update_links_where(|l| wanted.contains_key(&key(l)), |l| l.relative = wanted.get(&key(l)).cloned().flatten()); + Some(d) +} + +/// Sets each link of `d`, a copy of a document looking from `base`, to the path where that +/// document finds its file, and its relative path to it as that document's links have them, so +/// the copy finds the same files. Links in the layers, symbols, graph designs, patterns and opacity +/// masks are all set. +pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { + // The new path and relative path of each link by what `locate` reads of it: its path, relative + // path and name. + type PathAndRelative = (String, Option); + let mut found: BTreeMap> = BTreeMap::new(); + let mut look = |l: &LinkInfo| { + found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| { + let moved = locate(l, base)?.moved(l); + Some((moved.path, moved.relative)) + }); + }; + d.visit_images(|_, im| im.link.iter().for_each(&mut look)); + d.visit_placed(|_, p| look(&p.link)); + found.retain(|(path, _), f| f.as_ref().is_some_and(|(f, _)| f != path)); if found.is_empty() { return; } d.update_links(|l| { - if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) { - l.path = f.clone(); + if let Some(Some((path, relative))) = found.get(&(l.path.clone(), l.relative.clone())) { + l.path = path.clone(); + l.relative = relative.clone(); } }); } @@ -1074,15 +1165,15 @@ pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { fn check(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let ids = ids_param(p, "ids"); + let ids = ids_param(p, "ids", "links.check")?; let (mut missing, mut modified) = (0, 0); let mut links = vec![]; for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) { - let (status, found) = probe(g, st.path.as_deref()); + let (status, found) = probe(g, LinkBase::of(st)); missing += usize::from(status == Status::Missing); modified += usize::from(status == Status::Modified); let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) }); - note_refused(&mut row, &g.link, st.path.as_deref()); + note_refused(&mut row, &g.link, LinkBase::of(st).doc); if let Some(f) = found.filter(|f| f.path != g.link.path) { row["found"] = json!(f.path); } @@ -1095,7 +1186,7 @@ fn check(s: &mut Session, p: &Value) -> Result { } fn update(s: &mut Session, p: &Value) -> Result { - update_where(s, ids_param(p, "ids"), None) + update_where(s, ids_param(p, "ids", "links.update")?, None) } /// Update Links in the active document of `s` for objects `ids` (default: the linked files that @@ -1107,7 +1198,7 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - let (mut reads, mut missing) = (vec![], vec![]); let asked = ids.is_some() || path.is_some(); for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) { - match probe(&g, st.path.as_deref()) { + match probe(&g, LinkBase::of(st)) { (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), // Unchanged and showing the file: nothing to read unless asked for. (Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {} @@ -1220,14 +1311,14 @@ fn relink(s: &mut Session, p: &Value) -> Result { _ => None, }) }; - let mut ids: Vec = match (&each, ids_param(p, "ids")) { + let mut ids: Vec = match (&each, ids_param(p, "ids", C)?) { (Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(), (None, Some(ids)) => ids, (None, None) => { let selected: Vec = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect(); match folder_param { Some(_) if selected.is_empty() => { - groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect() + groups(&st.doc, None).into_iter().filter(|g| probe(g, LinkBase::of(st)).0 == Status::Missing).flat_map(|g| g.ids).collect() } _ => selected, } @@ -1363,7 +1454,7 @@ fn missing_in_folder( let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return }; for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) { let rest: Vec = g.ids.iter().copied().filter(|id| !given.contains(id)).collect(); - if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing { + if rest.is_empty() || probe(&g, LinkBase::of(st)).0 != Status::Missing { continue; } let found = absolute_path(&dir.join(g.link.name()).to_string_lossy()); @@ -1477,7 +1568,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { let mut out = BTreeMap::new(); for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) { - let (status, found) = probe(g, st.path.as_deref()); + let (status, found) = probe(g, LinkBase::of(st)); let found = found.map(|f| f.path).filter(|p| *p != g.link.path); let preview = stale(&st.doc, g); out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview)))); @@ -1598,7 +1689,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { Some(NodeKind::PlacedDocument(_)) => true, _ => false, }; - let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); + let ids = ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) { return Err(bad(C, format!("object {} is not an image or a placed document", id.0))); } @@ -1620,7 +1711,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { done.extend(g.ids); continue; } - match probe(&g, st.path.as_deref()) { + match probe(&g, LinkBase::of(st)) { (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) { Ok(read) => { done.extend(&g.ids); @@ -1709,8 +1800,9 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) } fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { - let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0); - let doc_path = st.path.as_deref(); + let base = LinkBase::of(st); + let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0); + let doc_path = base.doc; let path = fileio::document_path(&l.path, doc_path).ok(); let stamp = path.as_deref().and_then(file_stamp); out["status"] = json!(status.as_str()); @@ -1787,7 +1879,8 @@ fn info(s: &mut Session, p: &Value) -> Result { fn placement_options(s: &mut Session, p: &Value) -> Result { const C: &str = "links.placementOptions"; let st = s.doc()?; - let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); + let ids = + ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 54c65b0c7..b69b4e869 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -154,7 +154,7 @@ pub fn specs() -> Vec { "Make with Top Object", ["Object", "Envelope Distort"], Some("Cmd+Alt+C"), - "{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}", + "{ids?} the topmost selected path becomes the envelope of the others: its outline splits into four sides at its corners, and each piece of a side takes a share of the content in proportion to its control polygon's length (type maps from its glyph outline bounds) → {id}", has_multi, env_make_top ), diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs index 226a57a0d..80c9d714a 100644 --- a/crates/engine/src/cmd/live/common.rs +++ b/crates/engine/src/cmd/live/common.rs @@ -33,9 +33,10 @@ pub(super) fn roots_param(s: &Session, p: &Value) -> Result> { selected_roots(s) } -/// Selected nodes of a kind (the selection itself or an ancestor). +/// Selected nodes of a kind (the selection itself or an ancestor). None while an object of a +/// dynamic symbol instance's art is selected. pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -64,16 +65,19 @@ pub(super) fn is_mesh(n: &Node) -> bool { pub(super) fn has_blend(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) } } pub(super) fn has_envelope(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) } } pub(super) fn has_mesh(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + crate::cmd::edit::no_symbol_part(s)?; if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) } } diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 521f092b0..1aceb49b5 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -113,7 +113,7 @@ pub(super) fn env_make_top(s: &mut Session, p: &Value) -> Result { let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?; let defaults = envelope_defaults(s); let id = s.edit("Make Envelope", |d, sel| { - let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?; + let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path, corners: None }, defaults))?; d.remove(top)?; Ok(id) })?; @@ -223,7 +223,7 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result { fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option { let grey = Color::gray(0.25); let (rows, cols) = match kind { - EnvelopeKind::TopObject { path } => { + EnvelopeKind::TopObject { path, .. } => { return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0))); } // The mesh itself, handles and all. diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 01721471c..f2d322a39 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}", - can_edit_mask, + can_enter, enter ), cmd!( @@ -79,6 +79,13 @@ pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> { mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into())) } +/// Edit Opacity Mask: mask editing can be entered ([`can_edit_mask`]), and outside it no object of a +/// dynamic symbol instance's art is selected, since its instance's mask is not edited from it. +fn can_enter(s: &Session) -> std::result::Result<(), String> { + can_edit_mask(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode /// are not entered while an opacity mask is edited. pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> { @@ -117,12 +124,17 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let given = object_param(p, "id", C)?; + if given.is_none() { + super::edit::refuse_symbol_part(s)?; + } + let id = given.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = start(s, id)?; Ok(json!({ "layer": layer.0 })) } -/// The first selected object with an opacity mask. +/// The first selected object with an opacity mask. Callers refuse first while an object of a +/// dynamic symbol instance's art is selected ([`super::edit::refuse_symbol_part`]). fn first_masked(st: &crate::DocState) -> Option { st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.extras().mask.is_some())) } @@ -145,6 +157,9 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { st.revision += 1; return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); } + if given.is_none() && editing.is_none() { + super::edit::refuse_symbol_part(s)?; + } let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; match editing { Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 6992f42ab..4a48319f3 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -620,6 +620,9 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { if let Some(n) = d.node_mut(*id) { super::object::transform_node(n, m, sc, objects); } + if objects { + super::object::keep_compound_aligned(d, *id, sc); + } } Ok(targets) })?; @@ -1363,6 +1366,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { for (id, dl) in &art { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dl), sc); + super::object::keep_pixel_aligned(n, sc); } } let mut moved = vec![]; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index ade05bd95..c467f06e5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -25,6 +25,7 @@ mod arrange; mod bounds; mod commands; mod compound; +mod pixelgrid; mod props; mod shape; mod spans; @@ -35,5 +36,6 @@ pub(crate) use arrange::group_nodes; pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; +pub(crate) use pixelgrid::{keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned}; pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 156b35824..82a61b02a 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -112,7 +112,9 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & for (id, dv) in moves { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dv), sc); + super::pixelgrid::keep_pixel_aligned(n, sc); } + super::pixelgrid::keep_compound_aligned(d, *id, sc); } for &(i, pos) in &guides { if let Some(g) = d.guides.get_mut(i) { @@ -330,6 +332,7 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st a.translate(target(a.p) - a.p); } } + snap_anchor_path(d, *id); } Ok(()) })?; @@ -434,11 +437,23 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option Result { check_switches(p, &["patterns"], "object.distribute")?; check_numbers(p, &["artboard"], "object.distribute")?; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 69f464edb..f248294f2 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -2,6 +2,7 @@ use super::align::{align, can_distribute, distribute, distribute_spacing}; use super::arrange::{Arrange, arrange, exit_isolation, group, hide, isolate, lock, send_to_current_layer, show_all, ungroup, unlock_all}; use super::bounds::set_bounds; use super::compound::{clip_edit, clip_make, clip_release, compound_make, compound_release}; +use super::pixelgrid::set_pixel_aligned; use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; @@ -300,5 +301,14 @@ pub fn specs() -> Vec { has_selection, set_live_shape ), + cmd!( + "object.setPixelAligned", + "Align to Pixel Grid", + [], + None, + "{ids?|id?, on?: bool (default true)} → {ids (the objects whose property changed)} (Transform panel › Align to Pixel Grid: on, each object's horizontal and vertical straight segments move to whole points, or half points when a visible, painted centered stroke has an odd weight, its stroke weights become whole points (at least 1), and every later move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor (path.moveAnchor), path.setAnchors, object.setLiveShape, move of a compound path's path on its own, artboard move or resize that carries the art (artboard.move, artboard.setProps) or copies it (artboard.move with copy, Duplicate Artboards), every copy that edit.duplicate or a paste moves and object.slice.move of an object slice snaps them again, as does every stroke.set, stroke.weightUp or stroke.weightDown edit (which also rounds the new weight) and every appearance.setItem, appearance.addStroke, appearance.removeItem, appearance.clear, appearance.reduceToBasic, appearance.showAllHidden, appearance.transfer, appearance.copyFrom, paint.setStroke or graphicStyle.apply edit, and every other command that changes the object; diagonal segments and curves keep their shape apart from the anchors they share with those segments, and coordinates that differ stay at least 1 point apart; a live rectangle, polygon, line or pie moves and scales as a whole to the snapped bounds (a full ellipse, all curves, stays as it is), and a path with Live Corners snaps the outline its corners are cut from; a compound path snaps its paths with its own stroke, Make Compound Path from an aligned path or compound path gives an aligned compound path whose paths drop the property, and Release Compound Path gives each path the compound path's property; off, the object keeps its geometry and stops snapping. Only paths and compound paths can be aligned: type, images, groups, guides, paths inside a compound path and other objects are an error and nothing changes. One undo step; nothing changes and no undo step is made when every object already has the value. Saved in the native file)", + has_selection, + set_pixel_aligned + ), ] } diff --git a/crates/engine/src/cmd/object/compound.rs b/crates/engine/src/cmd/object/compound.rs index 1dabc76db..54e4d6f88 100644 --- a/crates/engine/src/cmd/object/compound.rs +++ b/crates/engine/src/cmd/object/compound.rs @@ -13,11 +13,15 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { d.node(backmost).ok_or(EngineError::NoNode(backmost))?; let mut units: Vec>> = vec![]; let mut appearance = None; + // The compound path is pixel-aligned when any of its paths was, or any compound path whose + // paths it takes; its paths snap with its stroke, so they do not keep the property themselves. + let mut aligned = false; for id in &ids { let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) { return Err(EngineError::Other("compound paths can only contain paths".into())); } + n.walk(&mut |c| aligned |= c.extras().pixel_aligned); compound_members(n, &mut appearance, &mut units); } let cid = d.alloc_id(); @@ -40,6 +44,7 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { for mut p in unit { let pn = Arc::make_mut(&mut p); pn.appearance = Appearance::default().into(); + pn.extras_mut().pixel_aligned = false; if let NodeKind::Path { path, live, .. } = &mut pn.kind { *live = None; if flip { @@ -49,6 +54,10 @@ pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { ch.push(p); } } + if aligned { + c.extras_mut().pixel_aligned = true; + super::pixelgrid::snap_if_aligned(&mut c); + } d.wrap(&ids, |_| c)?; sel.set([cid]); Ok(cid) @@ -99,6 +108,10 @@ pub(super) fn compound_release(s: &mut Session, _: &Value) -> Result { for c in children { let mut c = (**c).clone(); c.appearance = n.appearance.clone(); + // Each path keeps the compound path's pixel-aligned property. + if n.extras().pixel_aligned { + c.extras_mut().pixel_aligned = true; + } out.push(c.id); parts.push(c); } diff --git a/crates/engine/src/cmd/object/pixelgrid.rs b/crates/engine/src/cmd/object/pixelgrid.rs new file mode 100644 index 000000000..6441fda0c --- /dev/null +++ b/crates/engine/src/cmd/object/pixelgrid.rs @@ -0,0 +1,407 @@ +//! Transform panel › Align to Pixel Grid: an object's pixel-aligned property, and the snapping that +//! keeps the horizontal and vertical segments of a pixel-aligned object on the pixel grid +//! (). + +use vectorcraft_doc::{AppearanceItem, LiveShape, StrokeAlign}; + +use super::{ + Affine, Arc, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, Scaling, Session, Value, Vec2, bad, json, + selected_roots, +}; +use crate::cmd::{given_objects, switch_or}; + +const C: &str = "object.setPixelAligned"; + +/// How far apart two coordinates may be and still count as one, in points: segments this close to +/// horizontal or vertical count as horizontal or vertical. +const EPS: f64 = 1e-6; + +/// Whether `n` can be aligned to the pixel grid: a path that is not a guide, or a compound path. +/// Type, images, groups and other objects cannot. +pub(crate) fn can_align(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) +} + +/// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected +/// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be +/// one [`can_align`] accepts, else nothing changes. When every object already has the value, the +/// document and its undo history stay as they are. +pub(super) fn set_pixel_aligned(s: &mut Session, p: &Value) -> Result { + let on = switch_or(p, "on", true, C)?; + let ids = match given_objects(s, p, C)? { + Some(ids) => ids, + None => selected_roots(s)?, + }; + let d = &s.doc()?.doc; + let mut changed: Vec = vec![]; + for id in &ids { + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + // A path inside a compound path snaps with the compound path, whose property it takes. + let in_compound = d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if !can_align(n) || in_compound { + return Err(bad(C, "only paths and compound paths can be aligned to the pixel grid")); + } + if n.extras().pixel_aligned != on && !changed.contains(id) { + changed.push(*id); + } + } + if changed.is_empty() { + return Ok(json!({ "ids": [] })); + } + s.edit("Align to Pixel Grid", |d, _| { + for id in &changed { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + n.extras_mut().pixel_aligned = on; + if on { + snap_object(n, Scaling::default()); + } + } + Ok(()) + })?; + Ok(json!({ "ids": changed.iter().map(|i| i.0).collect::>() })) +} + +/// After a transform of `n` with `sc`: snap `n` and every object in it that is pixel-aligned to the +/// pixel grid, the art of linked opacity masks included. Objects that are not pixel-aligned stay as +/// they are. +pub(crate) fn keep_pixel_aligned(n: &mut Node, sc: Scaling) { + let mut found = vec![]; + aligned_within(n, &mut vec![], &mut found); + for at in found { + if let Some(a) = node_at(n, &at) { + snap_object(a, sc); + } + } +} + +/// One step from an object down to an object it holds: its child at an index, or the art of its +/// linked opacity mask. +#[derive(Clone, Copy)] +enum Step { + Child(usize), + Mask, +} + +/// Add to `out` the steps from the object [`keep_pixel_aligned`] started at to `n` (`at`) and to +/// every pixel-aligned object in `n`, in the art of linked opacity masks included, `n` first. One +/// walk visits each object once, so nothing is copied that does not snap. +fn aligned_within(n: &Node, at: &mut Vec, out: &mut Vec>) { + if n.extras().pixel_aligned { + out.push(at.clone()); + } + // The paths of a pixel-aligned compound path snap with it, but their opacity masks still need a visit. + for (i, c) in n.children().map_or(&[][..], Vec::as_slice).iter().enumerate() { + at.push(Step::Child(i)); + aligned_within(c, at, out); + at.pop(); + } + if let Some(m) = n.extras().mask.as_ref() + && m.linked + { + at.push(Step::Mask); + aligned_within(&m.art, at, out); + at.pop(); + } +} + +/// The object that `steps` lead to from `n`, made unique for editing. +fn node_at<'a>(n: &'a mut Node, steps: &[Step]) -> Option<&'a mut Node> { + let mut cur = n; + for s in steps { + cur = match s { + Step::Child(i) => Arc::make_mut(cur.children_mut()?.get_mut(*i)?), + Step::Mask => Arc::make_mut(&mut cur.extras_if_any()?.mask.as_mut()?.art), + }; + } + Some(cur) +} + +/// After a transform of object `id` with `sc`: when `id` is a path of a pixel-aligned compound path +/// (moved on its own, as with Group Selection), snap that compound path to the pixel grid again. +/// The compound path holds the property, so [`keep_pixel_aligned`] on the path alone does nothing. +pub(crate) fn keep_compound_aligned(d: &mut Document, id: NodeId, sc: Scaling) { + let Some(parent) = d.parent_of(id) else { return }; + if let Some(n) = d.node_mut(parent) + && matches!(n.kind, NodeKind::Compound { .. }) + && n.extras().pixel_aligned + { + snap_object(n, sc); + } +} + +/// After any edit ([`Session::edit`]): snap each pixel-aligned object that the edit changed or added +/// (its node in `doc` is not the one in `before`), with the pixel-aligned art of its linked opacity +/// mask, to the pixel grid. Objects the edit did not touch are not visited. +pub(crate) fn refresh(before: &Document, doc: &mut Document) { + let mut ids = vec![]; + changed_aligned(&before.layers, &doc.layers, &mut ids); + for id in ids { + if let Some(n) = doc.node_mut(id) { + keep_pixel_aligned(n, Scaling::default()); + } + } +} + +/// After a pass that moved what objects `ids` hold (Objects on Path placing its members): snap the +/// pixel-aligned objects in them to the pixel grid again. +pub(crate) fn keep_aligned_in(doc: &mut Document, ids: &[NodeId]) { + for id in ids { + if doc.node(*id).is_some_and(holds_aligned) + && let Some(n) = doc.node_mut(*id) + { + keep_pixel_aligned(n, Scaling::default()); + } + } +} + +/// The objects of `after` that differ from their counterparts in `before` (or are new) and are +/// pixel-aligned or hold pixel-aligned art in a linked opacity mask. Inside such an object the +/// walk stops: [`keep_pixel_aligned`] visits what it holds. +fn changed_aligned(before: &[Arc], after: &[Arc], out: &mut Vec) { + let mut by_id: Option>> = None; + for (i, a) in after.iter().enumerate() { + let b = match before.get(i) { + Some(b) if b.id == a.id => Some(b), + _ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(), + }; + if b.is_some_and(|b| Arc::ptr_eq(b, a)) { + continue; + } + if a.extras().pixel_aligned || a.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art)) { + out.push(a.id); + } else if let Some(children) = a.children() { + changed_aligned(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out); + } + } +} + +/// After an edit that replaces object `id`'s geometry: snap it, or the pixel-aligned compound path +/// that holds it, to the pixel grid again when it is pixel-aligned. +pub(crate) fn resnap(d: &mut Document, id: NodeId) { + if let Some(n) = d.node_mut(id) { + snap_if_aligned(n); + } + keep_compound_aligned(d, id, Scaling::default()); +} + +/// After an edit of `n`'s strokes: when `n` is pixel-aligned, round its stroke weights and snap it to +/// the pixel grid again, since a new weight or alignment can move where the stroke's edges fall. +pub(crate) fn snap_if_aligned(n: &mut Node) { + if n.extras().pixel_aligned { + snap_object(n, Scaling::default()); + } +} + +/// Whether `n` or an object in it, or in the art of a linked opacity mask in it, is pixel-aligned. +/// It goes only where a transform of `n` goes: not into unlinked masks. +fn holds_aligned(n: &Node) -> bool { + n.extras().pixel_aligned + || n.extras().mask.as_ref().is_some_and(|m| m.linked && holds_aligned(&m.art)) + || n.children().is_some_and(|ch| ch.iter().any(|c| holds_aligned(c))) +} + +/// Snap pixel-aligned object `n` to the pixel grid: its stroke weights become whole points (at +/// least 1), and its horizontal and vertical straight segments move to whole points, or to half +/// points when a centered stroke has an odd weight, so that the stroke's edges fall on the grid. +/// A compound path snaps each of its paths. Placed gradients and linked opacity masks of an object +/// whose anchors moved follow it ([`follow_snap`]). +fn snap_object(n: &mut Node, sc: Scaling) { + let half = round_strokes(n); + let before = n.geometric_bounds(); + let mut moved = false; + if let NodeKind::Compound { children, .. } = &mut n.kind { + // A path is copied for editing only when the snap moves it. + for c in children.iter_mut() { + if path_snaps(c, half) { + let was = c.geometric_bounds(); + let anchors = snaps_anchors(c); + let snapped = Arc::make_mut(c); + snap_path_node(snapped, half, sc); + if anchors { + follow_snap(snapped, was, sc); + } + moved = true; + } + } + } else if snaps_anchors(n) { + moved = snap_path_node(n, half, sc); + } else { + // A live shape is moved with `Node::transform`, which carries its gradients and mask along. + snap_path_node(n, half, sc); + } + if moved { + follow_snap(n, before, sc); + } +} + +/// Whether a snap moves the anchors of path `n` itself (a plain path, or one with Live Corners) +/// rather than transforming it as a whole (another live shape). +fn snaps_anchors(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Path { live, .. } if live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. }))) +} + +/// After the anchors of `n` moved in a snap, from bounds `before`: its placed gradients are fitted +/// to its new bounds, its patterns move and scale with those bounds when `sc` transforms patterns, +/// and the art of its linked opacity mask moves and scales with them (and snaps when it is +/// pixel-aligned itself). +fn follow_snap(n: &mut Node, before: Option, sc: Scaling) { + let (Some(f), Some(t)) = (before, n.geometric_bounds()) else { return }; + if f == t { + return; + } + n.appearance.rebase_gradients(f, t); + if sc.patterns { + vectorcraft_doc::pattern::transform_pattern_paints(n, rect_to_rect(f, t)); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && m.linked + { + let art = Arc::make_mut(&mut m.art); + art.transform(rect_to_rect(f, t), sc); + keep_pixel_aligned(art, sc); + } +} + +/// Round the stroke weights of `n` to whole points, at least 1 (strokes without weight stay +/// without). Returns whether a visible, painted centered stroke has an odd weight. +fn round_strokes(n: &mut Node) -> bool { + let rounds = |w: f64| if w > 0.0 { w.round().max(1.0) } else { w }; + let needs = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if rounds(st.width) != st.width)); + if needs { + for item in &mut n.appearance.items { + if let AppearanceItem::Stroke(st) = item { + st.width = rounds(st.width); + } + } + } + n.appearance.items.iter().any(|i| { + matches!(i, AppearanceItem::Stroke(st) if st.visible + && !matches!(st.paint, Paint::None) + && st.width > 0.0 + && st.width % 2.0 == 1.0 + && st.align == StrokeAlign::Center) + }) +} + +/// Snap path object `n`'s horizontal and vertical straight segments ([`snap_segments`]). A path +/// with Live Corners snaps the outline its corners are cut from. Another live shape is moved and +/// scaled as a whole instead, from its path's bounds to the bounds of the snapped path, so that it +/// stays a live shape. Returns whether it changed. +fn snap_path_node(n: &mut Node, half: bool, sc: Scaling) -> bool { + let NodeKind::Path { path, live, .. } = &mut n.kind else { return false }; + let Some(shape) = live.as_deref_mut() else { return snap_segments(path, half) }; + if let LiveShape::Path { base, .. } = shape { + if !snap_segments(base, half) { + return false; + } + *path = shape.to_path(); + return true; + } + let mut snapped = path.clone(); + if !snap_segments(&mut snapped, half) { + return false; + } + let (Some(from), Some(to)) = (path.bounds(), snapped.bounds()) else { return false }; + n.transform(rect_to_rect(from, to), Scaling { keep_corners: true, patterns: sc.patterns, ..Scaling::default() }); + true +} + +/// Whether [`snap_path_node`] would change path object `n`: whether an anchor of the path it snaps +/// moves. Another kind of object is not changed. +fn path_snaps(n: &Node, half: bool) -> bool { + let NodeKind::Path { path, live, .. } = &n.kind else { return false }; + match live.as_deref() { + Some(LiveShape::Path { base, .. }) => segment_moves(base, half).is_some(), + _ => segment_moves(path, half).is_some(), + } +} + +/// The move and scale that takes `from` onto `to` (a side without size is only moved). +fn rect_to_rect(from: Rect, to: Rect) -> Affine { + let sx = if from.width() > EPS { to.width() / from.width() } else { 1.0 }; + let sy = if from.height() > EPS { to.height() / from.height() } else { 1.0 }; + Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0)) +} + +/// Move the anchors of `path`'s horizontal straight segments to whole points vertically and the +/// anchors of its vertical ones to whole points horizontally (`half`: to half points), each anchor +/// with its handles, so curves and diagonal segments keep their shape apart from the anchors they +/// share with those segments. Coordinates that differ stay at least 1 point apart within a subpath, +/// so no side collapses. Returns whether an anchor moved. +pub(crate) fn snap_segments(path: &mut PathData, half: bool) -> bool { + let Some(moves) = segment_moves(path, half) else { return false }; + for (sp, m) in path.subpaths.iter_mut().zip(moves) { + for (a, d) in sp.anchors.iter_mut().zip(m) { + if d.x != 0.0 || d.y != 0.0 { + a.translate(d); + } + } + } + true +} + +/// How far [`snap_segments`] moves each anchor of each subpath of `path`, or `None` when it moves +/// no anchor. +fn segment_moves(path: &PathData, half: bool) -> Option>> { + let mut changed = false; + let mut moves = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + let (mut snap_x, mut snap_y) = (vec![false; n], vec![false; n]); + for i in 0..sp.segment_count() { + if !sp.segment_is_line(i) { + continue; + } + let (a, b) = (i % n, (i + 1) % n); + let (p, q) = (sp.anchors[a].p, sp.anchors[b].p); + let (dx, dy) = ((q.x - p.x).abs(), (q.y - p.y).abs()); + if dy <= EPS && dx > EPS { + (snap_y[a], snap_y[b]) = (true, true); + } else if dx <= EPS && dy > EPS { + (snap_x[a], snap_x[b]) = (true, true); + } + } + let xs = snapped_values(sp.anchors.iter().zip(&snap_x).filter(|(_, s)| **s).map(|(a, _)| a.p.x), half); + let ys = snapped_values(sp.anchors.iter().zip(&snap_y).filter(|(_, s)| **s).map(|(a, _)| a.p.y), half); + let mut m = Vec::with_capacity(n); + for (i, a) in sp.anchors.iter().enumerate() { + let dx = if snap_x[i] { snapped(&xs, a.p.x) - a.p.x } else { 0.0 }; + let dy = if snap_y[i] { snapped(&ys, a.p.y) - a.p.y } else { 0.0 }; + changed |= dx != 0.0 || dy != 0.0; + m.push(Vec2::new(dx, dy)); + } + moves.push(m); + } + changed.then_some(moves) +} + +/// The coordinates `values` and where each snaps to, in increasing order: the nearest whole point +/// (`half`: half point), and at least 1 point past where the coordinate before it snaps when the +/// two differ. +fn snapped_values(values: impl Iterator, half: bool) -> Vec<(f64, f64)> { + let mut sorted: Vec = values.filter(|v| v.is_finite()).collect(); + sorted.sort_by(f64::total_cmp); + let mut out: Vec<(f64, f64)> = vec![]; + for v in sorted { + match out.last() { + Some(&(last, _)) if v - last <= EPS => {} + Some(&(_, at)) => out.push((v, nearest(v, half).max(at + 1.0))), + None => out.push((v, nearest(v, half))), + } + } + out +} + +/// The whole point (`half`: half point) nearest `v`. A whole point halfway between two half points +/// goes to the half point above it, and a half point halfway between two whole points goes to the +/// whole point below it, so that switching between the two returns a coordinate to where it was. +fn nearest(v: f64, half: bool) -> f64 { + if half { (v + EPS).floor() + 0.5 } else { (v - 0.5 - EPS).ceil() } +} + +/// Where coordinate `v` snaps to, from [`snapped_values`] (`v` itself when it is not listed). A +/// coordinate merged into one listed is at most [`EPS`] past it. +fn snapped(values: &[(f64, f64)], v: f64) -> f64 { + let i = values.partition_point(|(x, _)| *x < v - EPS); + values.get(i).filter(|(x, _)| (x - v).abs() <= EPS).map_or(v, |(_, to)| *to) +} diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index eddc05763..2b32c5998 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -254,6 +254,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; recut(path, live, partial, &picked, radius, kind); } + for id in &ids { + super::pixelgrid::resnap(d, *id); + } Ok(()) })?; ok() diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index b400f637a..ae45f4d97 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -70,10 +70,12 @@ pub(crate) fn transforms_objects(p: &Value) -> bool { /// check them with [`check_switches`] before they change anything. pub(super) const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"]; -/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. +/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. The objects +/// in `n` that are aligned to the pixel grid snap to it again ([`super::pixelgrid::keep_pixel_aligned`]). pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { if objects { n.transform(xf, sc); + super::pixelgrid::keep_pixel_aligned(n, sc); } else if sc.patterns { vectorcraft_doc::pattern::transform_patterns_only(n, xf); } @@ -112,7 +114,7 @@ fn apply_transform_moving_guides( let c = xf.as_coeffs(); let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); - let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; + let with_guides = objects && p.get("ids").is_none_or(Value::is_null) && is_translation; let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); // A transform of ids that name no object, or an identity transform without a copy, moves // nothing: the document and its undo history stay as they are, and Transform Again repeats it. @@ -168,6 +170,11 @@ fn apply_transform_moving_guides( transform_node(n, xf, sc, objects); } } + if objects { + for id in &targets { + super::pixelgrid::keep_compound_aligned(d, *id, sc); + } + } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { if let Some(g) = d.guides.get_mut(i) { @@ -242,7 +249,12 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; check_switches(p, &["anchors", "releaseOutside"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; - let (ids, anchors) = match ids_param(p, "ids") { + // An object selected in a symbol instance's art stays where the symbol puts it: the selection + // is left as it is, and the reply's warning goes to the status bar. + if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { + return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); + } + let (ids, anchors) = match ids_param(p, "ids", "object.transform")? { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), }; diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index e2bd0957d..1a36a4e29 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -82,9 +82,10 @@ fn groups_of(d: &Document, ids: &[NodeId]) -> Vec { out } -/// The Objects on Path groups of the selection ([`groups_of`]). +/// The Objects on Path groups of the selection ([`groups_of`]). None while an object of a dynamic +/// symbol instance's art is selected. fn selected_groups(s: &Session) -> Vec { - s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() + s.active().filter(|st| st.selection.symbol_part.is_none()).map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default() } /// The groups a command acts on: those of `ids` when given, otherwise those of the selection. @@ -102,6 +103,7 @@ fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result> { fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) } } @@ -312,13 +314,18 @@ fn expand(s: &mut Session, p: &Value) -> Result { /// After an edit: put the objects of every Objects on Path group that changed back on its path /// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched. -pub(crate) fn refresh(before: &Document, doc: &mut Document) { +/// Returns the groups that placing changed. +pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Vec { let mut groups = vec![]; changed_groups(&before.layers, &doc.layers, &mut groups); + let mut placed = vec![]; for id in groups.iter().rev() { follow_replacements(before, doc, *id); - place_in(doc, *id); + if place_in(doc, *id) { + placed.push(*id); + } } + placed } /// Members of the group `id` that the edit replaced with new art at the same index keep their @@ -337,14 +344,17 @@ fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) { } /// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art. -fn place_in(doc: &mut Document, id: NodeId) { - let Some(n) = doc.node(id) else { return }; +/// Returns whether the group changed. +fn place_in(doc: &mut Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; let mut g = n.clone(); if place(&mut g, &doc.symbol_boxes()) && let Some(slot) = doc.node_mut(id) { *slot = g; + return true; } + false } /// The Objects on Path groups among `after` and inside them that are not the same nodes as before diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 7089770e4..9d81576b0 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)", - has_doc, + can_change_mask, release ), cmd!( @@ -43,25 +43,22 @@ pub fn specs() -> Vec { ["Window", "Transparency"], None, "{id?|ids?} keep the mask but stop applying it", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true })) ), - cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( - s, - p, - "Enable Opacity Mask", - &json!({ "disabled": false }) - )), + cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| { + set_flags(s, p, "Enable Opacity Mask", &json!({ "disabled": false })) + }), cmd!( "transparency.unlinkOpacityMask", "Unlink Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?} the object moves without its mask", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false })) ), - cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags( + cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", can_change_mask, |s, p| set_flags( s, p, "Link Opacity Mask", @@ -73,7 +70,7 @@ pub fn specs() -> Vec { [], None, "{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited", - has_doc, + can_change_mask, |s, p| set_flags(s, p, "Opacity Mask Options", p) ), cmd!( @@ -152,9 +149,21 @@ pub(crate) fn percent(o: f64) -> f32 { (o / 100.0).clamp(0.0, 1.0) as f32 } -/// The [`transparency_targets`] that have an opacity mask. +/// Refuses a command that would change [`transparency_targets`] when they are the selection, without +/// `id` or `ids` and outside mask editing, while an object of a dynamic symbol instance's art is +/// selected: the instance's transparency and mask change only when the instance is selected. +pub(crate) fn refuse_symbol_part_unnamed(s: &Session, p: &Value) -> Result<()> { + if p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_none() { + super::edit::refuse_symbol_part(s)?; + } + Ok(()) +} + +/// The [`transparency_targets`] that have an opacity mask, refused as +/// [`refuse_symbol_part_unnamed`] says. fn masked_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; + refuse_symbol_part_unnamed(s, p)?; let ids: Vec = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect(); if ids.is_empty() { return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into())); @@ -162,6 +171,13 @@ fn masked_targets(s: &Session, p: &Value) -> Result> { Ok(ids) } +/// The commands that change an existing opacity mask: a document is open, and outside mask editing +/// no object of a dynamic symbol instance's art is selected ([`masked_targets`]). +fn can_change_mask(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|st| st.doc.mask_edit.is_some()) { Ok(()) } else { super::edit::no_symbol_part(s) } +} + /// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty /// mask and takes into mask editing, is where mask editing can be entered /// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The @@ -194,7 +210,7 @@ fn make(s: &mut Session, p: &Value) -> Result { Some(id) => Err(bad(C, format!("object {} is hidden or locked, or on a locked artboard", id.0))), None => Ok(()), }; - let mut ids = match ids_param(p, "ids") { + let mut ids = match ids_param(p, "ids", C)? { Some(ids) => { if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) { return Err(EngineError::NoNode(*id)); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 7cab59481..7906a68e8 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -229,7 +229,7 @@ pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result { rich: bool_or(p, "includeCmyBlacks", false), spot: bool_or(p, "includeSpotBlacks", false), }; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", "edit.colors.overprintBlack")? { Some(v) => v, None => selected_roots(s)?, }; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index e102bf1f7..673ad2cbb 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -13,6 +13,7 @@ use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; use super::fileio::{create_dir, write_file}; +use super::links::LinkBase; use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name}; const C: &str = "file.package"; @@ -187,8 +188,18 @@ impl<'a> Collector<'a> { } /// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data, - /// whose links are not rewritten, so its copy finds them by name beside it. - fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> { + /// whose links are not rewritten, so its copy finds them by name beside it. `lookup`: the + /// document whose links (with the same paths as `doc`'s) are looked for, and where from, when + /// not `doc` from `source`. + fn collect( + &mut self, + doc: &mut Document, + source: &str, + destination: &str, + depth: usize, + by_name: bool, + lookup: Option<(&Document, LinkBase)>, + ) -> Result<()> { // The caller checks cycles and depth before descending; active documents have // a destination reserved so back-links can point at their single packaged copy. self.visiting.insert(source.to_string(), destination.to_string()); @@ -210,6 +221,10 @@ impl<'a> Collector<'a> { // The fonts of the type on the PDF and `.ai` pages placed linked. let mut placed_fonts = BTreeSet::new(); let mut placed = BTreeMap::::new(); + let linked_files = |doc: &Document| match lookup { + Some((d, base)) => super::links::linked_files(d, base), + None => super::links::linked_files(doc, LinkBase::file(Some(source))), + }; doc.visit_placed(|_, p| { let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box); let pages = placed.entry(p.link.path.clone()).or_default(); @@ -218,7 +233,7 @@ impl<'a> Collector<'a> { } }); if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() { - for file in super::links::linked_files(doc, Some(source)) { + for file in linked_files(doc) { if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) { placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages)); } @@ -226,7 +241,7 @@ impl<'a> Collector<'a> { } if self.opts.copy_links { heading(&mut lines, "LINKED FILES"); - for file in super::links::linked_files(doc, Some(source)) { + for file in linked_files(doc) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); if let Some(ancestor) = self.visiting.get(found_path).cloned() { // The ancestor is already included (possibly the root document). A @@ -272,7 +287,7 @@ impl<'a> Collector<'a> { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); } else { - self.collect(&mut art, found_path, &destination_file, depth + 1, true)?; + self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?; } } else if self.opts.copy_fonts && let Some(pages) = pages @@ -289,7 +304,7 @@ impl<'a> Collector<'a> { let include_linked = saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); let mut nested = saved.doc; - self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?; + self.collect(&mut nested, found_path, &destination_file, depth + 1, false, None)?; if self.opts.relink { #[cfg(test)] PARTS_READ.with(|c| c.set(c.get() + 1)); @@ -405,6 +420,8 @@ fn package(s: &mut Session, p: &Value) -> Result { pub struct Package { o: Options, doc_path: String, + /// The file the document's links' stored relative paths are taken from. + links_from: String, stem: String, name: String, root: Option, @@ -450,7 +467,8 @@ fn prepare(s: &mut Session, p: &Value) -> Result { let root = folder.map(|f| Path::new(f).join(&name)); let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None }; let doc = s.doc()?.doc.clone(); - Ok(Package { o, doc_path, stem, name, root, info, doc }) + let links_from = s.doc()?.links_from.clone().unwrap_or_else(|| doc_path.clone()); + Ok(Package { o, doc_path, links_from, stem, name, root, info, doc }) } impl Package { @@ -464,18 +482,27 @@ impl Package { /// [`Package::write`]. fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { - let Package { o, doc_path, stem, name, root, info, doc } = package; - let mut doc = (*doc).clone(); + let Package { o, doc_path, links_from, stem, name, root, info, doc: original } = package; + let mut doc = (*original).clone(); let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION); + let base = LinkBase { doc: Some(&doc_path), relative: Some(&links_from) }; + // After a save in another folder, links found away from their paths take the paths where they + // were found, as a copy of the document has them. + let found = (links_from != doc_path).then(|| { + super::links::with_found_paths(&mut doc, base); + doc.clone() + }); + let looked = found.as_ref().unwrap_or(&original); // Prepare original links for the destination *before* rebasing copied links. // Recalculating afterward would overwrite the portable relative paths. if let Some(root) = &root - && let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy()) + && let Some(d) = super::links::with_relative_paths(&doc, base, &root.join(&doc_name).to_string_lossy()) { doc = d; } let mut collector = Collector::new(&o, root.clone(), &doc_name); - collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?; + // The files are looked for where the links name them before the rebasing above. + collector.collect(&mut doc, &doc_path, &doc_name, 0, false, Some((looked, base)))?; let mut entries = std::mem::take(&mut collector.entries); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); let mut taken = std::mem::take(&mut collector.taken); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index bbb5f35d3..a2cd9a14a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,5 +1,7 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. +use std::borrow::Cow; + use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; @@ -19,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -28,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, a solid color or `none` changes that object for this instance only, and a gradient or pattern is refused", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -37,7 +39,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", has_doc, swap ), @@ -46,7 +48,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -86,7 +88,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -95,7 +97,7 @@ pub fn proxy_specs() -> Vec { "Complement", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model()) ), @@ -172,7 +174,7 @@ impl Session { /// new art. pub fn proxy_paints(&self) -> (Paint, Paint) { let show = |stroke: bool| match self.proxy_source(stroke) { - Some((n, item)) => proxy_paint(n, stroke, item), + Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), }; @@ -180,17 +182,22 @@ impl Session { } /// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item - /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. - pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(&Node, Option)> { + /// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. With + /// an object of a dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that + /// object as the instance draws it. + pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(Cow<'_, Node>, Option)> { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return Some((Cow::Owned(part), None)); + } let first = st.doc.node(*st.selection.subjects().first()?)?; let item = self.appearance_item(); if first.appearance.item_of_kind(item, !stroke).is_some() { - return Some((first, item)); + return Some((Cow::Borrowed(first), item)); } let mut nodes = vec![]; painted(first, true, &mut nodes); - nodes.first().map(|n| (*n, None)) + nodes.first().map(|n| (Cow::Borrowed(*n), None)) } pub(crate) fn remember_paint_now(&mut self, p: &Paint) { @@ -257,6 +264,9 @@ impl DocState { if self.selection.is_empty() { return (false, false); } + if self.selection.symbol_part.is_some() { + return (false, false); + } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); let (mut selected, mut nodes) = (vec![], vec![]); self.doc.walk(|n| { @@ -275,6 +285,18 @@ impl DocState { }; (differ(false), differ(true)) } + + /// The object of a dynamic symbol instance's art selected with Direct Selection + /// ([`vectorcraft_doc::Selection::symbol_part`]), with the instance's own appearance changes + /// applied, in the symbol's coordinates. `None` without one. + pub(crate) fn selected_symbol_part(&self) -> Option { + let sp = self.selection.symbol_part.as_ref()?; + let inst = self.doc.node(sp.instance)?; + let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { return None }; + let sym = self.doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst); + vectorcraft_doc::symbol_overrides::part(&art, &sp.path).cloned() + } } impl Session { @@ -465,6 +487,26 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) s.remember_paint(&paint); return Ok(json!({"objects": ids.len()})); } + // An object of a dynamic symbol instance's art selected with Direct Selection: a solid color or + // none changes that object for this instance only, and anything else is refused. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + if !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + super::brushsym::edit_part(s, id, &path, if fill { "Fill Color" } else { "Stroke Color" }, |o| { + if fill { + o.fill = Some(paint.clone()); + } else { + o.stroke = Some(paint.clone()); + } + })?; + paint_new_art(s, &paint, fill); + if focus { + s.fill_active = fill; + } + s.remember_paint(&paint); + return Ok(json!({"objects": 1})); + } // Resolve the requested targets before mutating defaults or the active proxy. let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; @@ -535,6 +577,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // A stroke without paint has no weight; painting it again restores the 1 pt default. stroke.width = 0.0; } + if !fill { + super::object::snap_if_aligned(n); + } Ok(()) }, |d, changed| { @@ -553,15 +598,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }, )?; // Rejected edits leave the next shape's appearance and active proxy untouched. - // New art takes an unplaced gradient so that it fits the new object's own bounds. - if fill { - s.paint.fill = unplaced(&paint); - } else { - if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { - s.paint.stroke_width = 1.0; - } - s.paint.stroke = unplaced(&paint); - } + paint_new_art(s, &paint, fill); if focus { s.fill_active = fill; } @@ -569,6 +606,39 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) Ok(json!({"objects": ids.iter().collect::>().len()})) } +/// The instance whose object is selected with Direct Selection, and that object's path +/// ([`super::brushsym::selected_part`]), when `p` names no `ids`, `id` or `item`: the paint commands +/// then change that object. A bad `target` or `aligned` is refused as it is for any other selection. +fn selected_part_target(s: &Session, p: &Value, cmd: &str) -> Result)>> { + if p.get("ids").is_some() || p.get("id").is_some() || p.get("item").is_some() { + return Ok(None); + } + let part = super::brushsym::selected_part(s)?; + if part.is_some() { + item_target(s, p, cmd)?; + } + Ok(part) +} + +/// Can an object of a dynamic symbol instance's art selected with Direct Selection take `paint`? A +/// solid color or none can; a gradient or pattern cannot. +pub(crate) fn part_takes(paint: &Paint) -> bool { + matches!(paint, Paint::Solid { .. } | Paint::None) +} + +/// Make `paint` the fill or stroke of new art. New art takes an unplaced gradient so that it fits +/// the new object's own bounds, and a painted stroke on art that had none gets the 1 pt default. +fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { + if fill { + s.paint.fill = unplaced(paint); + } else { + if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 { + s.paint.stroke_width = 1.0; + } + s.paint.stroke = unplaced(paint); + } +} + /// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. @@ -784,7 +854,9 @@ fn swap(s: &mut Session, p: &Value) -> Result { restore_unpainted_stroke(&mut n.appearance, None, &f); n.appearance.set_stroke(f); } - Ok(super::appearance::count_changed(d, &before, &ids)) + // The swap writes the paint of the topmost fill and stroke, not their opacity. + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -812,7 +884,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - Ok(super::appearance::count_changed(d, &before, &ids)) + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } super::newart::reset(s); @@ -824,6 +897,32 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { /// active item of the proxy's kind is recoloured in each selected object's own stack instead. fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p, cmd)?; + // An object of a dynamic symbol instance's art selected with Direct Selection: the solid color + // it shows is recolored for this instance only. A gradient or pattern is refused, and so is type + // whose characters differ in that paint, since one change holds one paint. + if let Some((id, path)) = selected_part_target(s, p, cmd)? { + let part = s.doc()?.selected_symbol_part(); + let mut paint = part.as_ref().map_or(Paint::None, |n| proxy_paint(n, stroke, None)); + let runs_differ = match part.as_ref().map(|n| &n.kind) { + Some(NodeKind::Text(t)) => t.runs.iter().any(|r| !same_in_proxy(if stroke { &r.style.stroke } else { &r.style.fill }, &paint)), + _ => false, + }; + if runs_differ || !part_takes(&paint) { + return Err(super::brushsym::part_only()); + } + let changed = super::colorcmds::map_paint(&mut paint, f); + if changed { + super::brushsym::edit_part(s, id, &path, label, |o| { + if stroke { + o.stroke = Some(paint.clone()); + } else { + o.fill = Some(paint.clone()); + } + })?; + s.remember_paint(&paint); + } + return Ok(json!({ "changed": changed as usize })); + } let ids = match super::given_objects(s, p, cmd)? { Some(ids) => ids, None => super::edit::selected_roots(s)?, @@ -851,7 +950,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) } }; let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; @@ -863,6 +962,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( fn transparency(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.set"; + super::opacitymask::refuse_symbol_part_unnamed(s, p)?; let ids = super::opacitymask::transparency_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 6eeafd7c3..2d31997c4 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the copies go together on that side of the artboards they copy, 20 pt away)} a copy of each artboard, the copies placed together right of the last artboard as they lay (or on `side`), with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", has_doc, artboard_duplicate ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", 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), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), 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), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), 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), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none 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\"|\"optical\", 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), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), 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), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), 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), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none 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 ), @@ -297,9 +297,11 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec } /// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep -/// their places relative to each other, together right of the last artboard. +/// their places relative to each other, together right of the last artboard, or with `side` +/// together on that side of the artboards they copy. fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; + let side = super::layer::artboard_side(p, "artboard.duplicate")?; let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?; let arts = artboards_art(s, &indices, art, locked_art, false)?; @@ -311,7 +313,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { .filter_map(|(i, _)| d.artboards.get(*i)) .map(|a| a.rect) .reduce(|a, b| a.union(b)) - .map(|r| beside_artboards(d, r)) + .map(|r| side.map_or_else(|| beside_artboards(d, r), |side| side.offset(r))) .unwrap_or_default(); let (mut index, mut ids) = (vec![], vec![]); for (i, art) in &arts { @@ -375,7 +377,10 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec if let Some(id) = d.artboards.get(index).map(|a| a.id) { d.copy_artboard_guides(src.id, id, dv); } - let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?; + let copies = super::edit::copy_above(d, art, |_, _, c| { + c.transform(Affine::translate(dv), sc); + super::object::keep_pixel_aligned(c, sc); + })?; Ok((index, copies)) } @@ -448,6 +453,19 @@ fn spacing_param(p: &Value, key: &str, c: &str) -> Result; 3 } } +/// The list level `listLevel` gives (None without one): a whole number from 1 to +/// [`vectorcraft_doc::text::LIST_LEVELS`], written `2` or `2.0`. +fn list_level_param(p: &Value, cmd: &str) -> Result> { + let key = "listLevel"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let (min, max) = (1, vectorcraft_doc::text::LIST_LEVELS); + match v.as_f64() { + Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(Some(x as u8)), + Some(x) => Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})"))), + None => Err(bad(cmd, format!("`{key}` must be a whole number"))), + } +} + fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; let ids = super::typecmd::text_targets(s, p, C)?; @@ -500,6 +518,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "list", "bullet", "number", + "listAlign", + "listLevel", "hyphenate", "wordsLongerThan", "afterFirst", @@ -620,6 +640,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result { &["none", "arabic", "upperLetters", "lowerLetters", "upperRoman", "lowerRoman", "twoDigits", "threeDigits"], )? .map(|n| NUMBERS.iter().find(|(k, _)| *k == n).map(|&(_, style)| style)); + // …and `listAlign` where the marker stands before the text, in every list paragraph. + let list_align = super::object::choice(p, C, "listAlign", &["left", "right"])? + .map(|a| if a == "right" { vectorcraft_doc::text::ListAlign::Right } else { vectorcraft_doc::text::ListAlign::Left }); + // …and `listLevel` the level of every list paragraph, 1 to 9. + let list_level = list_level_param(p, C)?; let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); @@ -716,6 +741,31 @@ fn set_format(s: &mut Session, p: &Value) -> Result { st.font_height_reference = v; } }; + // The list settings of one paragraph: `list`, `bullet`, `number`, `listAlign` and `listLevel`. + let lists = |para: &mut vectorcraft_doc::ParaStyle| { + if list.is_some() || bullet.is_some() || number.is_some() { + let on = list != Some("none") && bullet != Some(None) && number != Some(None); + let had = para.list; + // A numbered list for list "number", or without `list` for a number style. + let numbered = list == Some("number") || (list.is_none() && number.is_some()); + // The list's other settings (the marker's alignment) stay as they were. + para.list = on.then(|| vectorcraft_doc::text::ParaList { + bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), + number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), + ..had.unwrap_or_default() + }); + // A paragraph that joins a list with no left or first-line indent gets a + // hanging indent, unless the call sets an indent. + if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { + para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; + para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; + } + } + if let Some(l) = para.list.as_mut() { + l.align = list_align.unwrap_or(l.align); + l.level = list_level.unwrap_or(l.level); + } + }; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -741,22 +791,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = num("spaceAfter") { para.space_after = v.clamp(-MAX_PARA, MAX_PARA); } - if list.is_some() || bullet.is_some() || number.is_some() { - let on = list != Some("none") && bullet != Some(None) && number != Some(None); - let had = para.list; - // A numbered list for list "number", or without `list` for a number style. - let numbered = list == Some("number") || (list.is_none() && number.is_some()); - para.list = on.then(|| vectorcraft_doc::text::ParaList { - bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), - number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), - }); - // A paragraph that joins a list with no left or first-line indent gets a - // hanging indent, unless the call sets an indent. - if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { - para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; - para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; - } - } + lists(para); if let Some(v) = flag("hyphenate") { para.hyphenate = v; } @@ -848,14 +883,14 @@ fn set_format(s: &mut Session, p: &Value) -> Result { /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it -/// and the numbered paragraphs the frame counts before it. +/// and the numbered paragraphs the frame counts before it at each list level. struct ParagraphPart { frame: usize, index: usize, bytes: std::ops::Range, list: Option, marker: Option, - count: u32, + count: vectorcraft_doc::text::ListCounts, } /// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on @@ -897,7 +932,7 @@ fn marker_extent( lay: &vectorcraft_text::TextLayout, index: usize, bytes: std::ops::Range, - count: u32, + count: vectorcraft_doc::text::ListCounts, ) -> (f64, f64) { let width = |lay: &vectorcraft_text::TextLayout, start: usize| { lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| { @@ -911,7 +946,7 @@ fn marker_extent( probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end); probe.ruby.clear(); probe.set_all_paras(t.para_at(index).clone()); - probe.list_count = count; + probe.set_list_counts(count); probe.first_para_continues = false; width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0)) }) @@ -1063,7 +1098,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { let plains: Vec = frames.iter().map(|t| t.plain_text()).collect(); for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() { let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 }; - let mut count = t.list_count; + let mut count = t.list_counts(); let mut start = 0; for (k, piece) in plain.split('\n').enumerate() { let bytes = start..start + piece.len(); @@ -1125,8 +1160,9 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { // the left indent, nor one past it in a paragraph with no tab of its own. let style = t.para_at(para.index); let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count); - let pen = style.left_indent + style.first_line_indent + width; - let indent = style.left_indent; + // A list level past the first sets the paragraph further in than its left indent. + let indent = style.laid_out_left_indent(); + let pen = indent + style.first_line_indent + width; let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6); let end = pen + gap; let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x)); @@ -1158,6 +1194,8 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { // A paragraph in several frames is changed in its main part and in its empty parts: // re-flowing the thread gives the other parts with text the main part's style. let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue }; + // The paragraph keeps the indent its list level set it at. + style.left_indent = style.laid_out_left_indent(); style.list = None; if direction.is_some() { style.direction = direction; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index f2338d139..1d0601364 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -279,6 +279,29 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> _ => {} } } + // An SVG filter's XML is one drawable element. + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(xml) = given.get("xml") + { + let xml = xml.as_str().ok_or_else(|| bad(id, "`xml` must be a string"))?; + vectorcraft_render::effects::svg_filter::check(xml).map_err(|e| bad(id, e))?; + } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(scale) = given.get("scale") + && !scale.as_f64().is_some_and(|k| (0.001..=1000.0).contains(&k)) + { + return Err(bad(id, "`scale` must be a number from 0.001 to 1000")); + } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(origin) = given.get("origin") + && !origin.as_array().is_some_and(|o| o.len() == 2 && o.iter().all(|v| v.as_f64().is_some_and(f64::is_finite))) + { + return Err(bad(id, "`origin` must be [x, y] in points")); + } + // Its name is the filter's id. + if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { + return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); + } check_effect_color(id, effect, params)?; check_effect_channel(id, effect, params) } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e0de6bd4a..4cb6880f0 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Move Anchors", [], None, - "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected)", + "{dx, dy, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles)} move direct-selected anchors (whole paths if fully selected); refused while an object of a symbol instance's art is selected", has_selection, move_anchors ), @@ -226,6 +226,8 @@ fn close(s: &mut Session, p: &Value) -> Result { pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?); check_switches(p, &["patterns"], "path.moveAnchors")?; + // An object selected in a symbol instance's art stays where the symbol puts it. + super::edit::refuse_symbol_part(s)?; // Objects selected whole move as objects, and their pattern tiles move with them when // `patterns` (default: General › Transform Pattern Tiles) is on. let whole = s.doc()?.selection.whole(); @@ -260,11 +262,19 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge *a = a.transform(xf); } } + // A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps + // to the pixel grid again after its anchors move. + let top = compound_or_self(doc, *id); + if let Some(n) = doc.node_mut(top) { + super::object::snap_if_aligned(n); + } } None => { if let Some(n) = doc.node_mut(*id) { n.transform(xf, sc); + super::object::keep_pixel_aligned(n, sc); } + super::object::keep_compound_aligned(doc, *id, sc); } } } @@ -306,6 +316,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; *a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p)); + // A pixel-aligned path, or the pixel-aligned compound path that holds it, snaps to the + // pixel grid again. + let top = compound_or_self(d, id); + if let Some(n) = d.node_mut(top) { + super::object::snap_if_aligned(n); + } Ok(()) })?; ok() @@ -335,13 +351,14 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { ); s.edit("Reshape", |d, _| { *path_mut(d, id)? = data; + super::object::resnap(d, id); Ok(()) })?; ok() } /// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. -pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { +pub(crate) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) } @@ -580,7 +597,7 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result { }, }; - if let Some(ids) = ids_param(p, "ids") { + if let Some(ids) = ids_param(p, "ids", C)? { if ends.is_some() && ids.len() > 2 { return Err(bad(C, "`ends` applies to joining exactly two paths")); } @@ -1084,7 +1101,7 @@ fn average_join_ends(s: &Session, p: &Value, c: &str) -> Result let doc = &st.doc; let subpath = |id: NodeId, si: usize| doc.node(id)?.path_data()?.subpaths.get(si).filter(|sp| !sp.closed && !sp.anchors.is_empty()); let last = |sp: &SubPath| sp.anchors.len().saturating_sub(1); - let ids = ids_param(p, "ids"); + let ids = ids_param(p, "ids", c)?; let ends = if ids.is_none() && !st.selection.anchors.is_empty() { let picked: Vec = st.selection.anchors.iter().flat_map(|(&id, set)| set.iter().map(move |&(si, ai)| (id, si, ai))).collect(); match picked.as_slice() { diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 614a37d21..55c4390c2 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -189,8 +189,10 @@ fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> { has_doc(s) } +/// The repeats of the selection: selected ones, and those around a selected object. None while +/// an object of a dynamic symbol instance's art is selected. fn selected_repeats(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; + let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] }; let mut out = vec![]; for id in &st.selection.objects { for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { @@ -207,6 +209,7 @@ fn selected_repeats(s: &Session) -> Vec { fn has_repeat(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; + super::edit::no_symbol_part(s)?; if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) } } @@ -216,8 +219,8 @@ fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } -fn roots(s: &Session, p: &Value) -> Result> { - match ids_param(p, "ids") { +fn roots(s: &Session, p: &Value, cmd: &str) -> Result> { + match ids_param(p, "ids", cmd)? { Some(ids) => { let st = s.doc()?; let mut sel = Selection::default(); @@ -303,7 +306,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { if editing(s).is_some() { return Err(bad(C, "already in pattern editing mode")); } - let roots = roots(s, p)?; + let roots = roots(s, p, C)?; if roots.is_empty() { return Err(bad(C, "select the art for the pattern tile")); } @@ -662,9 +665,9 @@ fn pattern_transform(s: &mut Session, p: &Value) -> Result { // ---------- Repeat ---------- /// Make a repeat from the selection, or switch the selected repeat's kind. -fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec>) -> RepeatSpec) -> Result { +fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn(Vec>) -> RepeatSpec) -> Result { let existing = selected_repeats(s); - if ids_param(p, "ids").is_none() + if ids_param(p, "ids", cmd)?.is_none() && let [r] = existing.as_slice() { let r = *r; @@ -677,7 +680,7 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec Result { return Err(bad("object.repeat.radial", "instances must be 1..1000")); } let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0); - make_repeat(s, p, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) + make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) } fn repeat_grid(s: &mut Session, p: &Value) -> Result { let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64)); let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); - make_repeat(s, p, "Grid Repeat", |src| { + make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| { let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0)); let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0)); if let RepeatKind::Grid { rows: rr, cols: cc, .. } = &mut r.kind { @@ -717,7 +720,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result { fn repeat_mirror(s: &mut Session, p: &Value) -> Result { let angle = f64_or(p, "angle", 90.0); let offset = f64_or(p, "offset", 10.0); - make_repeat(s, p, "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) + make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) } fn repeat_release(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index b3e83ff22..3e15c563f 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -96,7 +96,9 @@ pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String { pub(crate) fn existing_pattern(d: &Document, lib: &Document, name: &str) -> Option { let def = lib.pattern(name)?; let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; - let copy = |n: &str| n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| r.parse::().is_ok()); + let copy = |n: &str| { + n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) + }; let mut found: Vec<&PatternDef> = d.patterns.iter().filter(|p| copy(&p.name) && bare(p) == bare(def) && p.art == def.art).collect(); found.sort_by_key(|p| p.name != name); found.first().map(|p| p.name.clone()) @@ -150,3 +152,19 @@ fn geometric_patterns(d: &mut Document) { add(d, "Crosses", 10.0, 10.0, TileType::Grid, vec![rect(4.0, 1.5, 6.0, 8.5), rect(1.5, 4.0, 8.5, 6.0)]); add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plus_signed_suffix_is_not_a_copy_number() { + let mut lib = Document::new(100.0, 100.0); + lib.patterns.push(PatternDef::new("Dots", vec![])); + for (other, expected) in [("Dots +2", None), ("Dots -2", None), ("Dots 2a", None), ("Dots ", None), ("Dots 2", Some("Dots 2"))] { + let mut d = Document::new(100.0, 100.0); + d.patterns.push(PatternDef::new(other, vec![])); + assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}"); + } + } +} diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index 822d88aad..d13e7d8c5 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -428,7 +428,7 @@ fn rulers(s: &mut Session, p: &Value) -> Result { /// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`): /// the plane and depth of the first one and the plane-space correction that lands the edge of the -/// joint bounds of the objects there nearer a gridline on it. +/// joint bounds of the objects on that plane, each measured at its own depth, nearer a gridline on it. pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> { let g = grid_of(doc); if !bool_or(p, "snap", g.snap) { diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 0de33a22c..db9bbf67d 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "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; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; 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), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{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; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; 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; refused while an object of a symbol instance's art is selected), 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), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -254,7 +254,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result Result { if corners.iter().flatten().any(|c| !(c.x.abs() <= MAX_COORD && c.y.abs() <= MAX_COORD)) { return Err(bad(PLACE, "at/rect lie outside the canvas")); } + // An object selected in a symbol instance's art can't be swapped, and its instance stays. + if replace { + super::edit::refuse_symbol_part(s)?; + } let st = s.doc()?; let old = match (replace, &st.selection.objects[..]) { (false, _) => None, diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 9bc52c89b..9f58e4851 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -133,7 +133,7 @@ pub(crate) fn document_art( // A document already being read further up, or past [`MAX_NESTED_READS`] of them, has its links // left as stored. if let Some(_reading) = Reading::enter(bytes) { - super::super::links::resolve(&mut l.doc, path, false, false); + super::super::links::resolve(&mut l.doc, super::super::links::LinkBase::file(path), false, false); } l.doc.drop_edit_modes(); let n = l.doc.artboards.len(); @@ -157,11 +157,11 @@ std::thread_local! { } /// A document's links being resolved ([`READING`]): the entry goes when this does. -struct Reading; +pub(super) struct Reading; impl Reading { /// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are. - fn enter(bytes: &[u8]) -> Option { + pub(super) fn enter(bytes: &[u8]) -> Option { let hash = hash_bytes(bytes); READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading) } @@ -332,7 +332,7 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) /// `d` for output: linked images and placed documents that keep only their previews get their /// files back, else output as their previews, with a warning. pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { - let (d, mut warnings) = super::super::links::full_linked_images(d, None); + let (d, mut warnings) = super::super::links::full_linked_images(d, Default::default()); let doc = match &d { Cow::Borrowed(doc) => doc, Cow::Owned(doc) => doc, diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..2ff5a37c3 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -24,13 +24,16 @@ //! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a //! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its //! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log -//! under its id. +//! under its id. One that lists `settings` or `cache` may keep settings and a cache of its own +//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]): +//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size +//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them. use std::sync::{Arc, Mutex}; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry}; +use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry, safe_mode}; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; @@ -139,7 +142,15 @@ impl FilterRun { let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); let may = m.permissions(); let st = s.doc()?; - let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, + // as `selected_roots` refuses it. + let selected = match ids_param(p, "ids", C)? { + Some(ids) => ids, + None => { + super::edit::refuse_symbol_part(s)?; + st.selection.objects.clone() + } + }; // Without `doc.read` a filter is given no objects: it can only add new ones. let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); @@ -332,13 +343,27 @@ fn grant_param(c: &str, p: &Value) -> Result>> { Ok(Some(out)) } +/// Would installing `plugin` take another module's place: one installed with its id and other +/// bytes, or, with none installed, the settings and cache the plug-in store keeps for its id? +fn takes_over(s: &Session, plugin: &Plugin) -> bool { + match registry::get(plugin.id()) { + Some(installed) => installed.hash() != plugin.hash(), + None => s.has_plugin_storage(plugin.id()), + } +} + /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id -/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant -/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. +/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted its id +/// unless `grant` lists them all, one that would take another module's place ([`takes_over`]) +/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with +/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; - let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); + let replace = match p.get("replace") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "replace", false, C)?), + }; + let allow_heavy = switch_or(p, "allowHeavy", false, C)?; let grant = grant_param(C, p)?; let (plugin, bytes) = load_given(C, p)?; // `hash` pins the module the user was shown: one with other bytes is refused. @@ -347,9 +372,21 @@ fn install(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); } - if !replace && registry::get(plugin.id()).is_some() { + if replace == Some(false) && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } + // Another module under the id gets the settings and cache kept for it: whoever installs it asks + // the user first. + if replace != Some(true) && takes_over(s, &plugin) { + let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME); + return Err(bad( + C, + format!( + "“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees", + plugin.id() + ), + )); + } // A heavy plug-in is installed once the user agrees: whoever installs it asks them first. if plugin.manifest().budget == Budget::Heavy && !allow_heavy { let (minutes, times) = vectorcraft_plugins::heavy_terms(); @@ -362,7 +399,8 @@ fn install(s: &mut Session, p: &Value) -> Result { )); } // A plug-in that lists permissions installs once the user grants them: whoever installs it - // asks them first, unless they already granted its id all of them. + // asks them first, unless they already granted all of them (with a plug-in store, to this + // module). let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { @@ -375,17 +413,34 @@ fn install(s: &mut Session, p: &Value) -> Result { registry::grant(g.clone()); s.store_grant(&m.id, Some(g)); } - // Installed plug-ins stay installed after a restart (#2541). - s.store_plugin(plugin.id(), &bytes); - Ok(describe(®istry::install(plugin))) + // Installed plug-ins stay installed after a restart (#2541), with their settings and cache. + let stored = s.store_plugin(plugin.id(), &bytes); + s.attach_plugin_storage(plugin.id()); + // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, + // also when it is disabled by its stored file alone (it stopped a launch while loading). When + // the new version could not replace the stored file, the old one stays disabled. + let file = s.stored_plugin_file(plugin.id()); + let mut changed = false; + if file.is_none() || stored { + changed = safe_mode::set_enabled(&safe_mode::Item::of(&plugin), true); + if let Some(file) = file { + changed |= safe_mode::set_enabled(&safe_mode::Item::file(file), true); + } + } + if changed { + s.store_disabled_plugins(); + } + Ok(describe_installed(®istry::install(plugin))) } -/// `plugin.inspect`: what installing the module would install, without installing it, and whether -/// that needs the user to grant its permissions first (`needsApproval`). +/// `plugin.inspect`: what installing the module would install, without installing it, whether that +/// needs the user to grant its permissions first (`needsApproval`) and whether it would take +/// another module's place (`needsReplace`). fn inspect(s: &mut Session, p: &Value) -> Result { load_given("plugin.inspect", p).map(|(plugin, _)| { let mut v = describe(&plugin); v["needsApproval"] = json!(!s.plugin_approved(&plugin)); + v["needsReplace"] = json!(takes_over(s, &plugin)); v }) } @@ -413,7 +468,8 @@ static FOLDER: Mutex, Value)>> = Mute /// (startup, `prefs.set`). Native only. pub(crate) fn sync_prefs(s: &Session) { let folder = s.prefs.plugins_folder.trim(); - if folder.is_empty() { + // In Safe Mode nothing loads, and the folder is loaded once it ends ([`load_prefs_folder`]). + if folder.is_empty() || safe_mode::active() { return; } let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); @@ -423,8 +479,24 @@ pub(crate) fn sync_prefs(s: &Session) { *last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder))); } +/// Turns Safe Mode on for `--safe-mode`, before the plug-in folder is attached: no plug-in loads or +/// runs in this process from then on. +pub fn start_in_safe_mode() { + safe_mode::set_active(Some(safe_mode::Source::Flag)); +} + +/// Loads the stored plug-ins and the Additional Plug-ins Folder preference: the app calls it when +/// the user leaves the choice after a launch that stopped with Disable or Start Normally, which +/// turns Safe Mode off first. +pub fn load_prefs_folder(s: &mut Session) { + s.load_stored_plugins(); + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = None; + sync_prefs(s); +} + /// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted -/// ([`Session::plugin_approved`], with the store's grants read once). +/// ([`Session::plugin_approved`], with the store's grants read once) → its report; saves the +/// disabled list when loading changed it. #[cfg(not(target_arch = "wasm32"))] fn load_folder(s: &Session, folder: &str) -> Value { if let Err(e) = crate::file_access::check_read(folder) { @@ -439,6 +511,12 @@ fn load_folder(s: &Session, folder: &str) -> Value { }; match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { + for id in &r.loaded { + s.attach_plugin_storage(id); + } + if r.disabled_changed { + s.store_disabled_plugins(); + } json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } Err(e) => json!({"folder": folder, "error": e.to_string()}), @@ -467,14 +545,73 @@ fn reload(s: &mut Session, p: &Value) -> Result { Ok(report) } +/// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`). +fn describe_installed(p: &Plugin) -> Value { + let mut v = describe(p); + v["cacheBytes"] = json!(registry::storage(p.id()).map_or(0, |st| st.cache_bytes())); + v +} + fn list(_: &mut Session, _: &Value) -> Result { let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); - Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder})) + let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); + Ok( + json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), + ) +} + +/// `plugin.safeMode`: whether Safe Mode is on and why, the plug-ins disabled for the next launch, +/// and the plug-in the last launch was loading when it stopped while the user hasn't chosen yet. +fn safe_mode_state(_: &mut Session, _: &Value) -> Result { + Ok(json!({ + "active": safe_mode::active(), + "source": safe_mode::source().map(safe_mode::Source::name), + "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>(), + "pendingItem": safe_mode::previous().map(|i| i.to_json()), + })) +} + +/// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a +/// plug-in that stopped a launch while loading may be known by its file alone). It neither loads +/// nor unloads anything now, and uninstalls nothing. +fn set_enabled(s: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.setEnabled"; + let Some(enabled) = p.get("enabled").and_then(Value::as_bool) else { + let key = "enabled"; + return Err(bad(C, format!("`{key}` must be true or false"))); + }; + let text = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Ok(Some(v.trim().to_string()).filter(|v| !v.is_empty())), + Some(_) => Err(bad(C, format!("`{key}` must be a string"))), + }; + // Turning on by id also matches the plug-in's stored file, which names it when it stopped a + // launch while loading. + let (item, stored) = match (text("id")?, text("file")?) { + (Some(id), _) => { + let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None }; + (registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored) + } + (None, Some(file)) => (safe_mode::Item::file(file), None), + (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), + }; + let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); + if enabled && !safe_mode::disabled().iter().any(|i| items.iter().any(|item| i.same(item))) { + return Err(bad(C, "that plug-in isn't disabled (see plugin.safeMode)")); + } + let mut changed = false; + for item in items { + changed |= safe_mode::set_enabled(item, enabled); + } + // Without a plug-in folder (a session confined to automation roots), or when the list there + // can't take the change, the change stays in this process and goes with the next change that saves. + let saved = !changed || s.store_disabled_plugins(); + Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>()})) } fn info(_: &mut Session, p: &Value) -> Result { let plugin = plugin_by_id("plugin.info", p)?; - let mut v = describe(&plugin); + let mut v = describe_installed(&plugin); v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null); v["defaults"] = Value::Object(plugin.manifest().defaults()); v["lastError"] = json!(effect::last_error(plugin.id())); @@ -483,15 +620,48 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + let installed = registry::get(id).is_some(); + // Settings and cache the store keeps for an id that isn't installed are deleted too. + let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id); + // Its settings and cache go first: when they can't be moved aside, the plug-in stays installed, + // as it was, and a second `plugin.remove` tries again. + if (installed || stored) + && let Err(e) = s.remove_plugin_storage(id) + { + if installed { + s.attach_plugin_storage(id); + } + return Err(EngineError::Other(e.to_string())); + } // Installed again, it asks again. registry::revoke(id); s.store_grant(id, None); s.unstore_plugin(id); - if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } + if !stored && (!installed || !registry::remove(id)) { + return Err(err(vectorcraft_plugins::Error::NotFound(id.into()))); + } + Ok(json!({"removed": id})) } -/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove` and -/// `.reload`. +/// `plugin.clearCache`: empties the cache of plug-in `id`, else of every installed plug-in → +/// `{cleared: [id], bytesFreed}`. Settings stay. +fn clear_cache(_: &mut Session, p: &Value) -> Result { + let plugins = match p.get("id").filter(|v| !v.is_null()) { + Some(_) => vec![plugin_by_id("plugin.clearCache", p)?], + None => registry::list(), + }; + let (mut cleared, mut freed) = (Vec::new(), 0_u64); + for plugin in plugins { + if let Some(st) = registry::storage(plugin.id()) { + freed += st.clear_cache().map_err(|e| EngineError::Other(e.to_string()))?; + } + cleared.push(plugin.id().to_string()); + } + Ok(json!({"cleared": cleared, "bytesFreed": freed})) +} + +/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`, +/// `.reload`, `.clearCache`, `.safeMode` and `.setEnabled`. pub fn specs() -> Vec { vec![ cmd!( @@ -499,7 +669,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -526,7 +696,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count, each for the module with its hash alone), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -535,19 +705,54 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)", always, inspect ), - cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove), + cmd!( + query "plugin.remove", + "Remove Plug-in", + [], + None, + "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", + always, + remove + ), + cmd!( + query "plugin.clearCache", + "Clear Plug-in Cache", + [], + None, + "{id?} delete every entry in the cache of plug-in id (default: of every installed plug-in); its settings stay → {cleared: [id], bytesFreed}", + always, + clear_cache + ), cmd!( query "plugin.reload", "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), + cmd!( + query "plugin.safeMode", + "Safe Mode", + [], + None, + "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", + always, + safe_mode_state + ), + cmd!( + query "plugin.setEnabled", + "Enable on Re-launch", + [], + None, + "{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", + always, + set_enabled + ), ] } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a702f1580..60f0f4565 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -150,11 +150,19 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("scaleCorners", "General", "Options", "Scale Corners", bool), p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool), p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool), + p!("ctrlWheelScrollsSideways", "General", "Options", "Scroll Sideways with Cmd/Ctrl + Wheel", bool), p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), + p!( + "marqueeSelection", + "Selection & Anchor Display", + "Selection", + "Marquee Selection", + choice(&[("touching", "Touching"), ("enclosed", "Fully Enclosed"), ("directional", "Drag Direction")]) + ), p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool), p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")), p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool), @@ -207,6 +215,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool), p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool), p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool), + p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool), p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text), // Units p!("unitsGeneral", "Units", "", "General", choice(UNITS)), @@ -340,6 +349,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool), p!("largeTabs", "User Interface", "", "Large Tabs", bool), p!("systemTitleBar", "User Interface", "", "System Title Bar", bool), + p!("appTitleBar", "User Interface", "", "VectorCraft Title Bar on Every Desktop", bool), p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")), p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool), // `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown. diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 938d2643a..d41a4529b 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -455,8 +455,9 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { enum Which { /// Every raster effect (PDF). All, - /// Effects SVG cannot represent faithfully, with their other raster effects. - Pixel { cmyk: bool }, + /// Effects SVG cannot represent faithfully, with their other raster effects; `filters`: the SVG + /// written has filters (SVG Tiny 1.2 has none). + Pixel { cmyk: bool, filters: bool }, } /// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image? @@ -464,8 +465,14 @@ fn imaged(n: &Node, which: Which) -> bool { let fx = raster_fx(n); match which { Which::All => !fx.is_empty(), - Which::Pixel { cmyk } => { - fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) + Which::Pixel { cmyk, filters } => { + // An SVG filter that is the object's last effect, and its only pixel effect, stays a filter. + let pixels = fx.iter().filter(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))).count(); + let live = filters + && pixels == 1 + && effects::svg_filter::writes_live(&n.appearance.effects) + && fx.iter().any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if s.in_points() && (!s.measured() || fx.len() == 1) && s.writes_as_svg11())); + (pixels > 0 && !live) || (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx))) } } @@ -592,7 +599,13 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { /// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its /// filters draw the other raster effects live), or `None` when there are none. pub fn flatten_pixel_effects(doc: &Document) -> Option { - flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc)) + flatten_pixel_effects_for(doc, true) +} + +/// [`flatten_pixel_effects`] for SVG that has filters (`filters`) or not (SVG Tiny 1.2): without +/// them an SVG filter (Effect › SVG Filters) also makes its object an image. +pub fn flatten_pixel_effects_for(doc: &Document, filters: bool) -> Option { + flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk, filters }, doc)) } fn flatten(doc: &Document, report: &mut FlattenReport) -> Option { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index f4996f4ad..00b87c649 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; use vectorcraft_color::{Color, Paint, Swatch}; -use vectorcraft_doc::swatches::{node_colors, node_paints}; +use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; @@ -99,7 +99,7 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> read(n, only, &mut nodes); } for m in nodes { - for p in node_paints(m) { + for p in node_paints(m).chain(override_paints(m)) { match p { Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()), Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())), @@ -522,6 +522,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { }; let row_swatches: Vec> = rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); + // While an object of a dynamic symbol instance's art is selected, the selection is refused, as + // `selected_roots` refuses it. + super::edit::refuse_symbol_part(s)?; let ids = s.doc()?.selection.objects.clone(); let group = text_param(p, "group", C)?; if ids.is_empty() && group.is_none() { diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 1ad456b94..a06597ac8 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -565,12 +565,20 @@ struct Meta { path: Option, /// The format Save writes for it ([`SAVE_FORMATS`]). format: String, + /// A document's own native PDF choice before the recovery copy omits its PDF. None keeps + /// inheritance from the current application preference. Older copies did not record a choice. + #[serde(skip_serializing_if = "Option::is_none")] + native_pdf_compatible: Option, /// When the copy was written (Unix seconds; none without a clock). saved: Option, /// The file it was read from when that import left things out of the document, and what /// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it. imported_from: Option, import_losses: Vec, + /// The file the links' relative paths name places from, for a copy without `path` (see + /// [`crate::DocState::links_from`]). + #[serde(skip_serializing_if = "Option::is_none")] + links_from: Option, /// The document's images by key, in entries of the area (none in copies written before /// images were kept apart: the document entry holds them). #[serde(skip_serializing_if = "BTreeMap::is_empty")] @@ -821,7 +829,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec) -> Result<(Vec, file: &str) -> R if doc.title.is_empty() { doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() }; } - // Linked files are looked for from the document's own folder. - let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); + // Linked files are looked for from the document's own folder, else from the file it was read from. + let links_from = meta.path.clone().or_else(|| meta.links_from.clone()); + let base = super::links::LinkBase::file(links_from.as_deref()); + let links = super::links::resolve(&mut doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); let index = s.add_document(doc, meta.path.clone()); let st = s.doc_mut()?; st.recovered = true; + st.links_from = links_from; st.imported_from = meta.imported_from.clone(); st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); + if let Some(pdf) = meta.native_pdf_compatible { + st.save_options.insert("pdfCompatible".into(), json!(pdf)); + } st.mark_unsaved(); // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index d175b4469..45f267048 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -62,7 +62,7 @@ impl Loader for Folder { /// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is /// decoded. -fn decode_head(b64: &str, max: usize) -> Option> { +pub(super) fn decode_head(b64: &str, max: usize) -> Option> { let chars = max.div_ceil(3).saturating_mul(4); let mut kept = 0usize; let end = b64 diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index c2092da0a..5792c44fd 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -204,7 +204,7 @@ impl Session { /// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones. fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let st = s.doc()?; - match ids_param(p, "slices") { + match ids_param(p, "slices", cmd)? { Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) { Some(id) => Err(bad(cmd, format!("{id} is not a slice"))), None => Ok(dedup(ids)), @@ -290,7 +290,7 @@ pub(crate) fn options_json(o: &SliceOptions) -> Value { // ---------- Object → Slice ---------- fn make(s: &mut Session, p: &Value) -> Result { - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", "object.slice.make")? { Some(ids) => super::edit::outermost(&s.doc()?.doc, ids), None => super::edit::selected_roots(s)?, }; @@ -708,7 +708,9 @@ fn move_slices(s: &mut Session, p: &Value) -> Result { None => { if let Some(n) = d.node_mut(*id) { n.transform(vectorcraft_geom::Affine::translate(v), vectorcraft_doc::Scaling::default()); + super::object::keep_pixel_aligned(n, vectorcraft_doc::Scaling::default()); } + super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default()); } } } @@ -732,7 +734,8 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn select(s: &mut Session, p: &Value) -> Result { - let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?; + let slices = p.get("slices").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])); + let ids = slice_targets(s, &json!({ "slices": slices }), "object.slice.select")?; let toggle = bool_or(p, "toggle", false); s.select(|_, sel| { let mut now = if toggle { sel.slices.clone() } else { vec![] }; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 4937ff200..1fb3373c7 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -266,6 +266,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(st) = n.appearance.stroke_at_mut(index) { set(st); } + super::object::snap_if_aligned(n); Ok(()) })?; // Commit the new-art template only after the complete request is validated @@ -329,6 +330,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { if let Some(st) = n.appearance.stroke_at_mut(index) { st.width = step_weight(st.width, unit, up); } + super::object::snap_if_aligned(n); Ok(()) })?; Ok(json!({"objects": ids.iter().collect::>().len()})) @@ -343,10 +345,15 @@ impl Session { /// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the /// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else /// the first selected object's (a group's first painted object's) top stroke, type's first - /// run's character stroke; with nothing selected, the next object drawn's - /// ([`Session::new_art_stroke`]). None without a document or the selection's stroke. + /// run's character stroke; with an object of a dynamic symbol instance selected + /// ([`DocState::selected_symbol_part`]), that object's as the instance draws it; with nothing + /// selected, the next object drawn's ([`Session::new_art_stroke`]). None without a document or + /// the selection's stroke. pub fn shown_stroke(&self) -> Option { let st = self.active()?; + if let Some(part) = st.selected_symbol_part() { + return node_stroke(&part); + } let Some(first) = st.selection.objects.first() else { return Some(self.new_art_stroke()) }; let first = st.doc.node(*first)?; if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) { @@ -360,13 +367,19 @@ impl Session { impl DocState { /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, - /// and whether Align Stroke applies to them (no open path among them). One walk of the - /// selection, so callers that ask every frame cache it by revision. + /// and whether Align Stroke applies to them (no open path among them). With an object of a + /// dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that object alone is + /// read. One walk of the selection, so callers that ask every frame cache it by revision. pub fn stroke_mixed(&self) -> StrokeMixed { + let part = self.selected_symbol_part(); let mut nodes = vec![]; - for id in &self.selection.objects { - if let Some(n) = self.doc.node(*id) { - painted(n, true, &mut nodes); + if let Some(part) = &part { + painted(part, true, &mut nodes); + } else { + for id in &self.selection.objects { + if let Some(n) = self.doc.node(*id) { + painted(n, true, &mut nodes); + } } } let mut strokes = vec![]; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 36780707f..5981396f1 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {name}", has_doc, style_redefine ), @@ -273,6 +273,7 @@ fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) { if let Some(n) = d.node_mut(id) { style_node(n, g, chars); n.extras_mut().graphic_style = Some(g.id); + super::object::snap_if_aligned(n); } } @@ -333,6 +334,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { s.new_art_style(&g, add); return Ok(json!({ "newArt": true })); } + let before = s.doc()?.doc.clone(); s.edit("Apply Graphic Style", |d, _| { if add { let g = d.graphic_styles[i].clone(); @@ -344,12 +346,17 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { n.extras_mut().graphic_style = None; } } + // The graph stores the rows of the kinds the style shows, now on top of each stack. + let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + let kinds = (shows(true), shows(false)); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); } Ok(()) })?; @@ -456,6 +463,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let look = GraphicStyle::of(String::new(), captured(n), n); let name = d.graphic_styles[i].name.clone(); let chars = s.prefs.override_char_color; + let before = s.doc()?.doc.clone(); 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 }); @@ -465,9 +473,10 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); let g = d.graphic_styles[i].clone(); - for nid in follow { - apply_style(d, nid, &g, chars); + for nid in &follow { + apply_style(d, *nid, &g, chars); } + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 2864f574b..7b0cad576 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -156,6 +156,9 @@ fn add(s: &mut Session, p: &Value) -> Result { let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?); if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)?; + } else if apply { + // A selection that can't take a style is refused before any style is added. + super::appearance::appearance_targets(s, &json!({}))?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/crates/engine/src/cmd/svgfilter.rs b/crates/engine/src/cmd/svgfilter.rs new file mode 100644 index 000000000..98b29a6e2 --- /dev/null +++ b/crates/engine/src/cmd/svgfilter.rs @@ -0,0 +1,234 @@ +//! Effect › SVG Filters: the SVG filters a document holds (`svgFilter.list`, `define`, `delete`, +//! `import`) and applying one to objects as a live `svg.filter` effect (`svgFilter.apply`). An +//! applied effect names the document's filter: editing the filter changes what every object that +//! uses it draws, and deleting it leaves their effects in place, drawing nothing, until a filter of +//! that name exists again. Each effect holds the definition it draws with, kept in step here. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Appearance, Document, SvgFilter}; +use vectorcraft_render::effects::{self, svg_filter}; + +use super::fileio::{absolute_path, read_file_head}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, text_param}; + +/// The largest SVG file Import SVG Filter reads. +const MAX_IMPORT_BYTES: usize = 16 << 20; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "svgFilter.list", + "SVG Filters", + [], + None, + "{} → {filters: [{name, xml}…]} the SVG filters the document holds, in the SVG Filters submenu's order", + has_doc, + list + ), + cmd!( + "svgFilter.define", + "New SVG Filter", + [], + None, + "{xml: one SVG element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter has is refused and a document holds at most 64 filters → {name, filters}", + has_doc, + define + ), + cmd!( + "svgFilter.delete", + "Delete SVG Filter", + [], + None, + "{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again → {filters}", + has_doc, + delete + ), + cmd!( + "svgFilter.import", + "Import SVG Filter…", + [], + None, + "{path?|svg?} add the elements of an SVG file (at most 16 MB; not SVGZ; its first 256 filters) or of SVG text to the document's SVG filters, as one undo step; a filter whose name the document already has, and one svgFilter.define would refuse, is left out; objects whose effect names an added filter draw with it → {added: [names…], skipped: count, filters}", + has_doc, + import + ), + cmd!( + "svgFilter.apply", + "Apply SVG Filter", + [], + None, + "{name, ids?|id?, item?} apply the document's SVG filter `name` (svgFilter.list) to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}", + has_doc, + apply + ), + ] +} + +fn filters_json(d: &Document) -> Value { + json!(d.svg_filters.iter().map(|f| json!({ "name": f.name, "xml": f.xml })).collect::>()) +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) +} + +/// `p`'s `name`, trimmed and not empty. +fn name_param(p: &Value, cmd: &str) -> Result { + let n = text_param(p, "name", cmd)?.map(str::trim).filter(|n| !n.is_empty()); + n.map(str::to_string).ok_or_else(|| bad(cmd, "give `name`, an SVG filter's name")) +} + +/// Is `e` an `svg.filter` effect that names filter `name`? +fn names(e: &vectorcraft_doc::Effect, name: &str) -> bool { + e.id == effects::SVG_FILTER && e.params.get("name").and_then(Value::as_str) == Some(name) +} + +/// The `svg.filter` effects of appearance `a` (its own and its fills' and strokes') that name +/// filter `name`. +fn named<'a>(a: &'a Appearance, name: &'a str) -> impl Iterator + 'a { + let items = a.items.iter().flat_map(|i| i.effects().iter()); + a.effects.iter().chain(items).filter(move |e| names(e, name)) +} + +/// Make the effects of appearance `a` that name filter `name` draw with `xml`. +fn update_appearance(a: &mut Appearance, name: &str, xml: &str) { + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items) { + if names(e, name) && e.params.is_object() { + e.params["xml"] = json!(xml); + } + } +} + +/// Does an effect in `d` that names filter `name` draw with something other than `xml` (`""`: +/// nothing)? Symbol definitions, graph designs, patterns, opacity masks and graphic styles count. +fn out_of_step(d: &Document, name: &str, xml: &str) -> bool { + let stale = |a: &Appearance| named(a, name).any(|e| e.params.get("xml").and_then(Value::as_str) != Some(xml)); + d.any_node(|n| stale(&n.appearance)) || d.graphic_styles.iter().any(|s| stale(&s.appearance)) +} + +/// Make every effect in `d` that names filter `name` draw with `xml` (`""` when the document no +/// longer has the filter: the effect keeps its name and draws nothing), symbol definitions, graph +/// designs, patterns, opacity masks and graphic styles included. +fn update_users(d: &mut Document, name: &str, xml: &str) { + // Through `DerefMut` only for an appearance that has such an effect (it is shared copy-on-write). + d.edit_nodes_where(|n| named(&n.appearance, name).next().is_some(), |n| update_appearance(&mut n.appearance, name, xml)); + for s in d.graphic_styles.iter_mut().filter(|s| named(&s.appearance, name).next().is_some()) { + update_appearance(&mut s.appearance, name, xml); + } +} + +fn define(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.define"; + let xml = match p.get("xml") { + Some(Value::String(x)) => x.trim().to_string(), + Some(_) => return Err(bad(C, "`xml` must be a string")), + None => return Err(bad(C, "give `xml`, one SVG element")), + }; + let replace = match p.get("replace") { + None | Some(Value::Null) => None, + Some(Value::String(r)) => Some(r.trim().to_string()), + Some(_) => return Err(bad(C, "`replace` must be a string")), + }; + svg_filter::check(&xml).map_err(|e| bad(C, e))?; + let filters = &s.doc()?.doc.svg_filters; + let at = match &replace { + Some(r) => Some(filters.iter().position(|f| f.name == *r).ok_or_else(|| bad(C, format!("the document has no SVG filter named {r:?}")))?), + None => None, + }; + // An edited filter keeps its name, whatever id its new code gives it. + let xml = match &replace { + Some(r) => svg_filter::with_id(&xml, r).ok_or_else(|| bad(C, "give one element"))?, + None => xml, + }; + let name = svg_filter::check(&xml).map_err(|e| bad(C, e))?; + if filters.iter().enumerate().any(|(i, f)| f.name == name && Some(i) != at) { + return Err(bad(C, format!("the document already has an SVG filter named {name:?}"))); + } + if at.is_none() && filters.len() >= SvgFilter::MAX { + return Err(bad(C, format!("a document holds at most {} SVG filters", SvgFilter::MAX))); + } + let new = SvgFilter { name: name.clone(), xml }; + // Nothing to record when the filter, and every object that uses it, is already as given. + if at.and_then(|i| filters.get(i)) != Some(&new) || out_of_step(&s.doc()?.doc, &new.name, &new.xml) { + let label = if at.is_some() { "Edit SVG Filter" } else { "New SVG Filter" }; + s.edit(label, |d, _| { + update_users(d, &new.name, &new.xml); + match at.and_then(|i| d.svg_filters.get_mut(i)) { + Some(f) => *f = new, + None => d.svg_filters.push(new), + } + Ok(()) + })?; + } + Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.delete"; + let name = name_param(p, C)?; + if !s.doc()?.doc.svg_filters.iter().any(|f| f.name == name) { + return Err(bad(C, format!("the document has no SVG filter named {name:?}"))); + } + s.edit("Delete SVG Filter", |d, _| { + d.svg_filters.retain(|f| f.name != name); + update_users(d, &name, ""); + Ok(()) + })?; + Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) +} + +/// The bytes of `p`'s `path` (an SVG file, read up to [`MAX_IMPORT_BYTES`]) or `svg`. +fn import_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (text_param(p, "path", cmd)?, text_param(p, "svg", cmd)?) { + // One byte past the cap is enough to refuse the file below. + (Some(path), _) => read_file_head(&absolute_path(path), MAX_IMPORT_BYTES + 1)?, + (None, Some(svg)) => svg.as_bytes().to_vec(), + _ => return Err(bad(cmd, "give `path`, an SVG file, or `svg`, SVG text")), + }; + if bytes.len() > MAX_IMPORT_BYTES { + return Err(bad(cmd, format!("the SVG file is larger than {MAX_IMPORT_BYTES} bytes"))); + } + Ok(bytes) +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.import"; + let bytes = import_bytes(p, C)?; + // SVGZ would unpack past the size read above. + if vectorcraft_svg::is_svgz(&bytes) { + return Err(bad(C, "Import SVG Filter reads SVG files, not SVGZ")); + } + let text = vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?; + let held = s.doc()?.doc.svg_filters.clone(); + let room = SvgFilter::MAX.saturating_sub(held.len()); + // Filters past the room left and names the document has are left out unchecked. + let (found, skipped) = vectorcraft_svg::svg_filters(&text, room, |name| held.iter().any(|f| f.name == name)) + .map_err(|e| bad(C, format!("the file isn't valid SVG: {e}")))?; + let added: Vec = found.into_iter().map(|(name, xml)| SvgFilter { name, xml }).collect(); + let names: Vec = added.iter().map(|f| f.name.clone()).collect(); + if !added.is_empty() { + s.edit("Import SVG Filter", |d, _| { + for f in &added { + update_users(d, &f.name, &f.xml); + } + d.svg_filters.extend(added); + Ok(()) + })?; + } + Ok(json!({ "added": names, "skipped": skipped, "filters": filters_json(&s.doc()?.doc) })) +} + +fn apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "svgFilter.apply"; + let name = name_param(p, C)?; + let f = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name).cloned(); + let f = f.ok_or_else(|| bad(C, format!("the document has no SVG filter named {name:?}")))?; + let mut q = json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }); + for key in ["ids", "id", "item"] { + if let Some(v) = p.get(key) { + q[key] = v.clone(); + } + } + super::effectcmd::apply(s, &q) +} diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index f19861685..2421441b9 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. A gradient swatch holds no link to the swatch its gradient came from, and a pattern swatch holds the pattern untransformed. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -187,7 +187,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { })), Paint::None => Err(bad(C, "a swatch needs a colour, gradient or pattern")), _ if spot || mode.is_some() || group.is_some() => Err(bad(C, "spot, mode and group apply to solid colours only")), - other => Ok(other), + // The swatch holds the gradient itself, not a link to the swatch it may have come from. + // It keeps `geom`, which holds the aspect the swatch is applied with. + Paint::Gradient(g) => Ok(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g }))), + // The swatch holds the pattern untransformed, not the placement of the art it came from. + Paint::Pattern { pattern, .. } => Ok(pattern_paint(&pattern)), }) .collect::>>()?; // A name applies to a single new swatch. diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 618ef2ee3..a3745975b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -65,7 +65,8 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.pattern_edit.is_some() { return Err(bad(C, "finish editing the pattern first")); } - let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty())); + let ids = super::ids_param(p, "ids", C)?; + let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| ids.filter(|v| !v.is_empty())); let (name, xf, instance) = match given { Some(ids) => { let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::>>(); @@ -78,6 +79,10 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { (name, xf, ids.first().copied()) } None => { + // Refused while an object of a dynamic instance's art is selected, unless `name` is given. + if str_param(p, "name").is_none() && st.selection.symbol_part.is_some() { + return Err(super::brushsym::part_only()); + } let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) }; let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect(); match symbols.first() { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 29d1de28f..a88562b13 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Bullets and Numbering", [], None, - "{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}", + "{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), \"* \" (a bulleted list with the filled dot), \"+ \" (the hollow circle), \"# \" (the filled square) or \"> \" (the dash), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}", has_doc, start_list ), @@ -1068,7 +1068,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { if sets_font { // Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's. let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } refresh_bounds(t); n = if *f == id { t.runs.len() } else { n }; @@ -1140,7 +1140,12 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R return Err(bad(c, "an inline graphic has no glyphs")); } let db = vectorcraft_text::FontDb::global(); - let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)); + let face = match db.composite_resolve_at(&run.style.font_family, &run.text, start - at) { + // Type in a composite font: the character is drawn in the font of the part its + // grapheme cluster falls in. + Some((face, m)) => (m != vectorcraft_text::FontMatch::Missing).then_some(face), + None => db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)), + }; let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?; return Ok((ch, start..start + ch.len_utf8(), face)); } @@ -1602,6 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { system_layout: false, fractional_widths: true, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1890,6 +1896,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { system_layout: t.system_layout, fractional_widths: t.fractional_widths, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 17b2f0f0c..68394e7fd 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -327,7 +327,7 @@ fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map /// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by /// Cancel, and brush art, including opacity masks in those trees. -fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { +pub(crate) fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { fn has_text(n: &Node) -> bool { matches!(n.kind, NodeKind::Text(_)) || n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art)) diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 61a21e506..ae15ab8b9 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -168,7 +168,17 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } } let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None }; - let frames: Vec = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect(); + let vertical = if live.len() >= 2 { thread_setting(before, doc, &live, |t| t.vertical) } else { None }; + // The story is measured in each frame with the settings the frame takes. + let frames: Vec = live + .iter() + .filter_map(|id| text_of(doc, *id).cloned()) + .map(|mut t| { + t.optical_margin = optical.unwrap_or(t.optical_margin); + t.vertical = vertical.unwrap_or(t.vertical); + t + }) + .collect(); let refs: Vec<&TextObject> = frames.iter().collect(); let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s); for (id, part) in live.iter().zip(parts) { @@ -177,19 +187,24 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { next.runs = part.runs; next.set_paragraph_styles(part.paras); let optical = optical.unwrap_or(t.optical_margin); + let vertical = vertical.unwrap_or(t.vertical); let continues = part.continues; if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin + || vertical != t.vertical || part.list_count != t.list_count + || part.list_level_counts != t.list_level_counts || continues != t.first_para_continues { t.runs = next.runs; t.para = next.para; t.paras = next.paras; t.optical_margin = optical; + t.vertical = vertical; t.list_count = part.list_count; + t.list_level_counts = part.list_level_counts; t.first_para_continues = continues; refresh_bounds(t); } @@ -203,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } /// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count -/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a -/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of -/// its thread) gets 0 and is laid out again. +/// from 1. Each such type object with another [`TextObject::list_count`] or +/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame +/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is +/// laid out again. pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { let mut stale = vec![]; doc.walk(|n| { if let NodeKind::Text(t) = &n.kind - && t.list_count != 0 + && (t.list_count != 0 || !t.list_level_counts.is_empty()) { stale.push(n.id); } @@ -227,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { for id in stale { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { t.list_count = 0; + t.list_level_counts.clear(); refresh_bounds(t); } } @@ -235,11 +252,37 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { /// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes: /// that of the first frame whose setting changed between `before` and `doc`, else the first frame's. fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option { - let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin); - let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id))); + thread_setting(before, doc, live, |t| t.optical_margin) +} + +/// The value of the frame setting `get` that every frame of a thread with the frames `live` takes: +/// that of the first frame whose value changed between `before` and `doc`, or the first frame's value +/// when none changed. +fn thread_setting(before: &Document, doc: &Document, live: &[NodeId], get: impl Fn(&TextObject) -> T) -> Option { + let now = |id: &NodeId| text_of(doc, *id).map(&get); + let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(get(t)) != now(id))); changed.or(live.first()).and_then(now) } +/// Turn Auto Size off for the frames of each thread of two or more frames, as [`reflow`] does, +/// without re-flowing the story: for a document restored from a file as it was saved, so a later +/// change to a frame's settings keeps the frame's size. +pub(crate) fn keep_frame_sizes(doc: &mut Document) { + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + if live.len() < 2 { + continue; + } + for id in live { + if text_of(doc, id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight) + && let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) + { + t.area.fit = vectorcraft_doc::AreaFit::None; + } + } + } +} + /// Give every frame of each thread the first frame's Optical Margin Alignment setting, without /// re-flowing the story: for a document restored from a file as it was saved. pub(crate) fn share_optical_margin(doc: &mut Document) { @@ -257,6 +300,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) { } } +/// Give every frame of each thread the first frame's writing direction, and re-flow each thread in +/// which that changes a frame: for a document restored from a file as it was saved. +pub(crate) fn share_orientation(doc: &mut Document) { + let before = doc.clone(); + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + let Some(vertical) = thread_setting(doc, doc, &live, |t| t.vertical) else { continue }; + // Only the frames whose direction changes are copied, so only their threads re-flow. + let turn: Vec = live.into_iter().filter(|id| text_of(doc, *id).is_some_and(|t| t.vertical != vertical)).collect(); + for id in turn { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.vertical = vertical; + refresh_bounds(t); + } + } + } + reflow(&before, doc); +} + /// Mark the frames of each thread whose first paragraph continues from the frame before /// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored /// from a file as it was saved. @@ -386,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) { { // Split as `take_overflow` splits it: the first part is what the frame shows. let from: &TextObject = t; - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( vectorcraft_text::FontDb::global(), &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) { t.runs = head.runs.clone(); @@ -427,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) { // `from`'s text split as if it flowed on into a second frame: the second part is what `from` // has no room for. Its share is measured as it is laid out, continuing a paragraph or not and // with its numbered list paragraphs counted on from the frames before it. - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( db, &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return }; let mut tail = from.clone(); diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 71d175b3a..a1358557c 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); } +/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the +/// count the frames before it reached at that level. +#[test] +fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..])); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); +} + +/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's +/// art holds the text the frame shows, measured with the count the frames before it reached at +/// that level, and the frame before it takes the rest. +#[test] +fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() { + for command in ["symbol.new", "symbol.update"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})) + .unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(laid.overflow, "{command}: the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + let name = if command == "symbol.update" { + let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [mark]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + "Mark" + } else { + "Story" + }; + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute(command, &json!({"name": name})).unwrap(); + let held = symbol_texts(&s, name); + assert_eq!(held.iter().map(|t| t.trim_end()).collect::>(), [shown[1][..end].trim_end()], "{command}: the symbol's art"); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest"); + } +} + /// A story of one-line paragraphs that each start with a quotation mark, threaded from area /// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. fn quoted_thread() -> (Session, u64, u64) { @@ -1090,6 +1158,177 @@ fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() { } } +/// The writing direction of each of the frames `ids` (true: vertical). +fn vertical(s: &Session, ids: [u64; N]) -> [bool; N] { + ids.map(|id| { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") }; + t.vertical + }) +} + +/// Whether each frame of the thread `ids` holds the part of the story that its layout, in its +/// writing direction, has room for: the parts that [`vectorcraft_text::thread::distribute`] +/// gives the frames as they are now. +fn flows_as_laid_out(s: &Session, ids: &[u64]) -> bool { + let d = &s.doc().unwrap().doc; + let frames: Vec<&TextObject> = ids.iter().map(|id| text_of(d, NodeId(*id)).unwrap()).collect(); + let story = vectorcraft_text::thread::story(&frames); + let paras = vectorcraft_text::thread::story_paras(&frames, &[]); + let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &frames, &story, ¶s); + let texts = |runs: &[TextRun]| runs.iter().map(|r| r.text.as_str()).collect::(); + parts.iter().zip(&frames).all(|(part, f)| texts(&part.runs) == f.plain_text()) +} + +/// Type Orientation is a setting of the whole thread: Type › Type Orientation in any frame sets +/// it in every frame of the thread, in one undo step, and the story flows through the frames in +/// that direction. Typing keeps it, and frames taken out of the thread keep it. +#[test] +fn type_orientation_is_set_for_every_frame_of_a_thread() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("type.orientation.vertical", &json!({})).unwrap(); + assert_eq!(r, json!({"vertical": true, "ids": [a, b]})); + assert_eq!(vertical(&s, [a, b]), [true, true], "the second frame is vertical too"); + assert!(flows_as_laid_out(&s, &[a, b])); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + // Set back from the second frame, both are horizontal, and one undo makes both vertical. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + assert_eq!(s.execute("type.orientation.horizontal", &json!({})).unwrap()["ids"], json!([b, a])); + assert_eq!(vertical(&s, [a, b]), [false, false]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + // Typing in the first frame re-flows the story and keeps the direction. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + assert!(flows_as_laid_out(&s, &[a, b])); + // Any edit that sets one frame sets the thread. + s.edit("Set", |d, _| { + let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = false; + refresh_bounds(t); + Ok(()) + }) + .unwrap(); + assert_eq!(vertical(&s, [a, b]), [false, false]); + assert!(flows_as_laid_out(&s, &[a, b])); + s.execute("edit.undo", &json!({})).unwrap(); + // Released or unthreaded, each frame keeps it. + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b]), [true, true]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert_eq!(vertical(&s, [a, b]), [true, true]); +} + +/// Threading type gives the joined thread the writing direction of its first frame, and the +/// story flows through every frame in that direction. +#[test] +fn a_thread_takes_its_first_frames_type_orientation() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let area = json!({"width": 150, "height": 60}); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": area, "vertical": true})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 60, "height": 200})).unwrap()); + let c = new_id(s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!(vertical(&s, [a, b, c]), [true; 3]); + assert!(!text(&s, b).is_empty() && !text(&s, c).is_empty()); + assert!(flows_as_laid_out(&s, &[a, b, c]), "the middle frame holds what fits it as vertical type"); + // From `c`'s out port into horizontal area type `d`: `d` joins the thread and becomes vertical. + let d = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap()); + s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap(); + assert_eq!(threads(&s), [[a, b, c, d]]); + assert_eq!(vertical(&s, [a, b, c, d]), [true; 4]); + assert!(flows_as_laid_out(&s, &[a, b, c, d])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(vertical(&s, [d]), [false], "undo gives `d` its own direction back"); +} + +/// A file saved with the frames of one thread in different writing directions opens with the +/// first frame's direction in every frame, and the story flows through them in it: a native +/// file, and an EPS file restored from the document it carries. +#[test] +fn a_thread_saved_with_differing_orientations_opens_with_the_first_frames() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] { + for (id, on) in [(a, first), (b, !first)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = on; + refresh_bounds(t); + } + let bytes = if name.ends_with(".eps") { + let r = s.execute("document.exportEps", &json!({})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } else { + vectorcraft_format::save(&s.doc().unwrap().doc, false) + }; + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], name.ends_with(".eps"), "{r}"); + assert_eq!(threads(&s), [[a, b]], "{name}"); + assert_eq!(vertical(&s, [a, b]), [first, first], "{name}, first frame vertical: {first}"); + assert!(flows_as_laid_out(&s, &[a, b]), "{name}, first frame vertical: {first}"); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4), "{name}"); + } +} + +/// A thread restored from an EPS file whose frames share one writing direction keeps its text +/// in its frames as saved. +#[test] +fn a_restored_thread_in_one_direction_keeps_its_text_as_saved() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let story = TEXT.repeat(4); + for (id, part) in [(a, &story[..10]), (b, &story[10..])] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.runs = vec![TextRun::new(part.to_string(), t.runs[0].style.clone())]; + refresh_bounds(t); + } + let r = s.execute("document.exportEps", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], true, "{r}"); + assert_eq!((text(&s, a), text(&s, b)), (story[..10].to_string(), story[10..].to_string())); +} + +/// A threaded frame restored from a file with Auto Size on keeps its size when it turns from +/// vertical to horizontal: as the file opens, when the first frame is horizontal, and with Type +/// Orientation, when the whole thread is vertical. +#[test] +fn a_restored_threaded_frame_with_auto_size_keeps_its_size_when_it_turns_horizontal() { + for first_vertical in [false, true] { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + for (id, on) in [(a, first_vertical), (b, true)] { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") }; + t.vertical = on; + if id == b { + t.area.fit = vectorcraft_doc::AreaFit::AutoHeight; + } + refresh_bounds(t); + } + let saved = frame_box(&s, b); + let r = s.execute("document.exportEps", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let r = s.execute("document.open", &json!({"name": "saved.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["restored"], true, "{r}"); + if first_vertical { + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("type.orientation.horizontal", &json!({})).unwrap(); + } + assert_eq!(vertical(&s, [a, b]), [false, false], "first frame vertical: {first_vertical}"); + assert_eq!(frame_box(&s, b), saved, "first frame vertical: {first_vertical}"); + assert!(flows_as_laid_out(&s, &[a, b]), "first frame vertical: {first_vertical}"); + } +} + /// One count runs through a thread: the second frame's numbered paragraphs go on from the /// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a /// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks @@ -1154,6 +1393,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() { assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); } +/// List levels in a thread: a deeper level's count runs on into the next frame, which saves +/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1. +#[test] +fn list_levels_count_on_through_a_thread() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap(); + let numbers = + |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>(); + let first = numbers(&frame(&s, a)); + assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); + let expected: Vec = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect(); + assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected); + let b_counts = frame(&s, b).list_counts(); + assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1])); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); + assert_ne!(copy, NodeId(b)); + let pasted = frame(&s, copy.0); + assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..])); + assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1"); +} + /// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art /// looks as it did; Break Link puts the frames in no thread, and each counts from 1. #[test] diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4ad577651..c0e5b37fe 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -16,15 +16,21 @@ use super::{ pub fn specs() -> Vec { vec![ - cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| { - orientation(s, p, true) - }), + cmd!( + "type.orientation.vertical", + "Vertical", + ["Type", "Orientation"], + None, + "{ids?|id?} set vertical writing for the selected type and every frame of its threads → {vertical, ids}", + has_selection, + |s, p| orientation(s, p, true) + ), cmd!( "type.orientation.horizontal", "Horizontal", ["Type", "Orientation"], None, - "{ids?|id?} set horizontal writing", + "{ids?|id?} set horizontal writing for the selected type and every frame of its threads → {vertical, ids}", has_selection, |s, p| orientation(s, p, false) ), @@ -139,8 +145,10 @@ pub fn specs() -> Vec { ] } +/// Type › Type Orientation: set vertical or horizontal writing on every selected text object and +/// every frame of their threads, in one undo step. fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { - let ids = text_targets(s, p, "Text Orientation")?; + let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Text Orientation")?); s.edit("Text Orientation", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { @@ -911,7 +919,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { super::textedit::unprotected_runs(&t.runs, a, b, &kept) } }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } if let Some(j) = justify { let span = para_span(range, t); @@ -1271,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> { let cell = lay.frames.first()?; let line = lay.lines.first()?; let para = t.para_at(0); - let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent; + let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent; Some((line.x1 - line.x0, avail, line.end)) } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 4748dcc8d..0891bec5c 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; - assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); @@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); - let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); + let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() }); assert_eq!(list(&s, 0), letters); assert_eq!(numbers(&s), ["a.", "2.", "3."]); assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); @@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); } +/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker +/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values; +/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes +/// between bullets and numbers, and a call that puts a paragraph in a list gives it the +/// alignment too. The alignment is saved only when it is right, opens with the file, and +/// paragraph styles carry it both ways. +#[test] +fn list_markers_are_aligned_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + 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\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::>(); + let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right)); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap(); + assert_eq!(align(&s), [right, left, None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap(); + assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one"); + for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing"); + // Bullets and numbers keep it; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap(); + assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap(); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"})); + assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right)))); + // A list turned off and on again starts aligned left. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(align(&s)[1], left); + // A style captured from a list aligned right gives other lists that alignment, and one + // captured from a list aligned left takes it back. + let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.para_at(0).list.map(|l| l.align), right); + s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap(); + assert_eq!(align(&s), [left, left, left]); +} + /// The text object `id`. fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject { match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { @@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) { assert_eq!(s.doc().unwrap().history.undo.len(), steps); } +/// Convert to Text of a list whose markers are aligned right writes the same characters as for +/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops: +/// the text after the tab stays where it was, and the marker moves to the first line's start, +/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went +/// to the left indent) and wider (their tab went to the next default stop), and bullets. +#[test] +fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text_glyphs = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let milli = |v: f64| (v * 1000.0).round() as i64; + lay.glyphs + .iter() + .filter(|g| g.len > 0 && !g.outline.elements().is_empty()) + .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y))) + .collect::>() + }; + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + for (list, converted) in [ + ("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n")), + ("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::>().join("\n")), + ] { + let make = |s: &mut Session, align: &str| { + let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap() + ["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap(); + id + }; + let (left, right) = (make(&mut s, "left"), make(&mut s, "right")); + let drawn_left = drawn_glyphs(&s, left); + let text_before = text_glyphs(&s, right); + assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment"); + assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere"); + s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap(); + assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab"); + let after = text_glyphs(&s, right); + assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was"); + assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them"); + assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays"); + } +} + /// A right-to-left list paragraph converted to text starts with the number, its period and a /// tab, which are read first: the line draws its glyphs in the order it drew them with the /// marker, the number at the right with its period at its left. Letters and roman numerals @@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() { assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); } +/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those +/// a thread's earlier frames counted at each level included, and every glyph is drawn where it +/// was: each converted paragraph's left indent takes its level's indent, with the plain left +/// stop there. +#[test] +fn convert_to_text_keeps_deeper_list_levels_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let numbers = |s: &Session, id: u64| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + let text_in = "One\nTwo\nThree\nFour\nFive"; + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); + let t = text(&s, id); + assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive"); + let paras = (0..5) + .map(|k| { + let p = t.para_at(k); + (p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::>()) + }) + .collect::>(); + assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent"); + // A thread whose second frame goes on with the numbers of level 2. + let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::>().join("\n"); + let a = + s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let first = numbers(&s, a).len(); + assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}"); + assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on"); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); + let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::>().join("\n"); + assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story); +} + +/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches +/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a +/// list turned off and on starts at level 1 again, each level counts its own numbers, and the +/// level is saved past level 1, reopened and carried by paragraph styles. +#[test] +fn list_levels_are_set_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; + 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\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::>(); + let numbers = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap(); + assert_eq!(levels(&s)[1], Some(2)); + s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap(); + assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one"); + let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})]; + for p in bad { + assert!(s.execute("text.setFormat", &p).is_err(), "{p}"); + } + let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string(); + assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}"); + assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing"); + // A deeper paragraph counts from 1, and one back at a shallower level goes on. + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap(); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + // Bullets and numbers keep the level; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3))); + s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap(); + assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4))); + assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3})); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((0..4).map(|i| back.para_at(i).list).collect::>(), (0..4).map(|i| text(&s).para_at(i).list).collect::>()); + // A list turned off and on again starts at level 1. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic))); + // A style captured from a paragraph at level 4 sets that level, and one captured at level 1 + // takes it back. + s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap(); + assert_eq!(levels(&s)[0], Some(4)); + s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap(); + assert_eq!(levels(&s), [Some(1); 4]); +} + /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. #[test] fn create_outlines_makes_a_path_of_the_bullet() { @@ -1627,9 +1865,10 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); - s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); + s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Source Sans 3"}})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - // A static font has no axes to set. + // A static font has no axes to set (Source Sans 3, not Inter: an installed Inter + // Variable would shadow the bundled static and give it axes). s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); assert_eq!(saved(&s), None); // A missing font keeps them as given, for when it is installed. @@ -1651,7 +1890,7 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, ]) ); - assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); + assert_eq!(s.execute("text.fontList", &json!({"family": "Source Sans 3"})).unwrap()["axes"], json!([])); } #[test] diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 83dfd925d..0b0ea1042 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", + "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", has_selection, path_options ), @@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result { } // The point origin sits where the alignment anchors the first line. let p0 = t.para_at(0); - let (x0, x1) = - lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent)); + let (x0, x1) = lay + .lines + .first() + .map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent)); let ox = match p0.justify { Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5, Justify::Right | Justify::JustifyRight => x1, @@ -611,6 +613,9 @@ pub(crate) fn alt_code_char(code: u64) -> Option { fn unicode_char(u: &str) -> Option { let u = u.trim(); let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u); + if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t') } @@ -843,3 +848,18 @@ fn paste_plain(s: &mut Session, p: &Value) -> Result { } r } + +#[cfg(test)] +mod tests { + use super::unicode_char; + + #[test] + fn unicode_char_rejects_sign_in_hex_digits() { + assert_eq!(unicode_char("+B0"), None); + assert_eq!(unicode_char("U++B0"), None); + assert_eq!(unicode_char("U+"), None); + assert_eq!(unicode_char("00B0"), Some('°')); + assert_eq!(unicode_char("U+00B0"), Some('°')); + assert_eq!(unicode_char("0x00B0"), Some('°')); + } +} diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 426479070..16762c1e5 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -181,6 +181,21 @@ fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId) -> String { } } +/// Refuse to bind the text variable `name` to type that already has a different text variable: +/// a type object holds one text variable. The error names the one it has (the first defined, in +/// a document that holds two). An object already bound to `name` is not refused, and variables +/// of other kinds are not counted. +fn check_one_text_variable(doc: &vectorcraft_doc::Document, ids: &[NodeId], name: &str, cmd: &str) -> Result<()> { + for id in ids { + let Some(bound) = doc.variables.bindings.get(id).filter(|b| !b.contains(name)) else { continue }; + let held = doc.variables.variables.iter().find(|v| v.kind == VariableKind::Text && bound.contains(&v.name)); + if let Some(v) = held { + return Err(bad(cmd, format!("node {} already has the text variable `{}`", id.0, v.name))); + } + } + Ok(()) +} + fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result { match kind { VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())), @@ -284,6 +299,9 @@ fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result Result { let doc = &s.doc()?.doc; let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; check_bindable(doc, &ids, kind, C)?; + if kind == VariableKind::Text { + check_one_text_variable(doc, &ids, &name, C)?; + } // Binding again is not a second binding, and says so rather than counting as a change. let already: Vec = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect(); let n = edit_variables(s, "Bind Variable", |v| Ok(ids.iter().filter(|id| v.bind(**id, &name)).count()))?; @@ -738,7 +759,7 @@ pub fn specs() -> Vec { "Bind Variable", ["Window", "Variables"], None, - "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. A type object holds one text variable: binding it to another text variable is refused, and the error names the one it has. Variables of other kinds can share an object, and binding an object to a variable it already has changes nothing → {bound, already}", has_doc, bind ), diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 998109f3d..7af7cf1f8 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -414,6 +414,7 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result { for id in art { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(dv), sc); + super::object::keep_pixel_aligned(n, sc); } } index.push(*i); diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index a115498e1..9fcb7cd4c 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -73,6 +73,11 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { } } +/// Journaled commands that write a file and change nothing in the document: with an object of a +/// dynamic symbol instance's art selected, they read the instance as queries do +/// ([`crate::Session::execute`]). +pub(crate) const JOURNALED_READS: &[&str] = &["css.export"]; + /// Commands that act on the selection and take `ids` by selecting them first (`select.set`, with /// its checks), then running as on the selection: the result and the selection afterwards are the /// menu command's (#2130). @@ -116,13 +121,14 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ /// The non-empty `ids` given to a command whose params description names `ids` as a key (the /// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of /// one when no `ids` are given and the description takes `id` so -/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. +/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. Entries of `ids` that are +/// not ids are left out of this list; `Session::execute` refuses them before it reads it. pub(crate) fn given_ids(description: &'static str, params: &Value) -> Option> { if !cmd::params::takes_key(description, "ids") { return None; } match params.get("ids").filter(|v| !v.is_null()) { - Some(_) => cmd::ids_param(params, "ids"), + Some(v) => v.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()), None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]), } .filter(|ids| !ids.is_empty()) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 0baab9654..e70bf5c2d 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -95,6 +95,11 @@ pub struct DocState { /// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved. pub imported_from: Option, pub import_losses: Vec, + /// The file its links' stored relative paths name places from: the file the document was read + /// from, also when that file is not its own (such as a `.ai` file another app wrote). A save + /// writes the saved file's relative paths and leaves the open document's links as they are. + /// `None`: the document's own file. Not saved ([`crate::cmd::links::LinkBase`]). + pub links_from: Option, /// Transform Again after a perspective move or scale (Perspective Selection tool): the /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. pub last_perspective: Option, @@ -296,6 +301,7 @@ impl DocState { layers_open, imported_from: None, import_losses: vec![], + links_from: None, last_perspective: None, opened_by: file_access::current(), empty_artboards: Default::default(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 399a4ed11..27972ef20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,6 +62,14 @@ pub struct Session { /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). pub(crate) lender: Option>, + /// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's + /// gesture found, so that one that leaves it as it was picks nothing. + pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>, + /// The selection a lent tool's gesture changed and left as it gave `lender` back, in the + /// document (its uid) it was made in: while it is still that document's selection, the anchors + /// in it are ones picked, not the Pen's last anchor, and Copy and Cut take them with the Pen or + /// Curvature tool back too (#6123). + pub(crate) lent_selection: Option<(u64, vectorcraft_doc::Selection)>, /// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, @@ -164,6 +172,13 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// The composite fonts (lowercase names) that the open documents defined when the font + /// database was last brought in step with them ([`cmd::compositefonts::sync`]). + pub(crate) composite_names: Vec, + /// While a command that changes nothing in the document runs (a query, or one of + /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol + /// instance's art. + pub(crate) reading: bool, /// While [`Session::place_read`] runs `file.place`: the file read for it, which the command /// places instead of reading the file; `None` otherwise. pub(crate) place_hand_off: Option, @@ -245,6 +260,9 @@ pub struct Session { /// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not /// saved. pub global_edit_options: cmd::globaledit::Options, + /// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool + /// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]). + keyboard_text: bool, } impl Default for Session { diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 82946ac29..e1346b457 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -54,6 +54,8 @@ pub struct Prefs { /// Scale Strokes & Effects. pub scale_strokes: bool, pub zoom_with_mouse_wheel: bool, + /// While Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls sideways instead of zooming. + pub ctrl_wheel_scrolls_sideways: bool, /// A horizontal drag on a numeric field or its label steps its value (#400). pub scrub_numeric_fields: bool, /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. @@ -65,6 +67,8 @@ pub struct Prefs { pub paste_offset: f64, // Selection & Anchor Display pub selection_tolerance: f64, + /// Selection-tool marquee: touching (default), enclosed, or screen drag direction. + pub marquee_selection: String, pub object_selection_by_path_only: bool, pub snap_to_point_tolerance: f64, pub ctrl_click_selects_behind: bool, @@ -95,6 +99,9 @@ pub struct Prefs { /// font is highlighted, in every document (on by default). pub highlight_substituted_fonts: bool, pub placeholder_text: bool, + /// Type › Start Lists From Typed Markers: a list marker and a space typed at the start of a + /// paragraph start a list (on by default). Off, they stay as typed text. + pub typed_lists: bool, // Units pub units_general: String, pub units_stroke: String, @@ -167,6 +174,10 @@ pub struct Prefs { /// acting as the title bar (tiling window managers, desktops that draw their own decorations). /// Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, + /// Linux and the BSDs: draw the app bar as the title bar in sessions where the desktop draws + /// the frame (X11, Wayland compositors with server-side decorations, XWayland), as on GNOME. + /// Read when the app starts; System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it. + pub app_title_bar: bool, // Performance pub gpu_performance: bool, pub animated_zoom: bool, @@ -367,11 +378,13 @@ impl Default for Prefs { scale_corners: false, scale_strokes: false, zoom_with_mouse_wheel: false, + ctrl_wheel_scrolls_sideways: true, scrub_numeric_fields: true, trackpad_rotate_view: true, hold_tool_keys: true, paste_offset: 10.0, selection_tolerance: 3.0, + marquee_selection: "touching".into(), object_selection_by_path_only: false, snap_to_point_tolerance: 2.0, ctrl_click_selects_behind: true, @@ -399,6 +412,7 @@ impl Default for Prefs { highlight_alternate_glyphs: false, highlight_substituted_fonts: true, placeholder_text: true, + typed_lists: true, units_general: s("points"), units_stroke: s("points"), units_type: s("points"), @@ -447,6 +461,7 @@ impl Default for Prefs { scale_cursor_with_ui: false, interface_language: s("auto"), system_title_bar: false, + app_title_bar: false, gpu_performance: true, animated_zoom: true, gpu_preference: s("automatic"), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1eaf15b88..27976ff51 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,4 +1,4 @@ -use super::command_sets::{SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; +use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only}; use super::doc_state::DocState; use super::error::{EngineError, Result}; use super::history::NamedStep; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ec9fdbac1..b0619c992 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -42,6 +42,8 @@ impl Session { journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, + selection_at_lend: None, + lent_selection: None, last_selection_tool: None, last_view: ViewInfo::default(), preview_memo: Default::default(), @@ -81,6 +83,8 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + composite_names: Vec::new(), + reading: false, place_hand_off: None, queue_hand_off: None, place_queue_roots: None, @@ -109,6 +113,7 @@ impl Session { link_stamps: Default::default(), link_scan: None, global_edit_options: Default::default(), + keyboard_text: false, } } @@ -172,10 +177,16 @@ impl Session { pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { - if self.active != Some(index) { + let switched = self.active != Some(index); + if switched { self.reset_tool_for_doc_switch(); } self.active = Some(index); + // Type is drawn in the composite fonts of the document now in front, and measured again + // when they differ from those of the document before. + if switched && cmd::compositefonts::sync(self) { + self.refresh_document_text_bounds(index); + } true } else { false @@ -192,7 +203,7 @@ impl Session { format!("Untitled-{}", self.untitled_counter) } - /// Text layout bounds are a cache (not saved): compute them for a document just read, or + /// Text layout bounds are a cache (not saved): compute them after its fonts are registered, or /// selection boxes and hit testing would use the rough estimate until each text is edited, and /// what is fitted to type's bounds (an envelope warping it) would come out differently. That /// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too, @@ -207,6 +218,14 @@ impl Session { } } + /// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an + /// edit: a saved document stays saved, and its undo history keeps its documents as they are. + fn refresh_document_text_bounds(&mut self, index: usize) { + if let Some(st) = self.docs.get_mut(index) { + st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc))); + } + } + /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, /// an undo group with the begun group's name opens in it: here outside a command, and after the /// command that adds it otherwise ([`Session::execute`]). @@ -214,7 +233,7 @@ impl Session { // Every edit is checked against the document before it, so the first one is within the cap // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -225,6 +244,8 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); + cmd::compositefonts::sync(self); + self.refresh_document_text_bounds(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -240,7 +261,7 @@ impl Session { return false; } doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); doc.adopt_guides(); cmd::dimension::conform(&mut doc); if self.active == Some(index) { @@ -276,6 +297,12 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } + // Type in the composite fonts of a document in the back is measured again when it comes to + // the front. + if self.active == Some(index) { + cmd::compositefonts::sync(self); + } + self.refresh_document_text_bounds(index); true } @@ -292,6 +319,7 @@ impl Session { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; + cmd::compositefonts::sync(self); true } diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index da285eb3d..7fcbdc912 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -26,7 +26,11 @@ impl Session { if let Some(ge) = &st.global_edit { spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]); } - cmd::objects_on_path::refresh(&before, doc); + // Pixel-aligned objects the edit changed go back on the pixel grid. + cmd::refresh_pixel_aligned(&before, doc); + let placed = cmd::objects_on_path::refresh(&before, doc); + // Pixel-aligned members that placing moved go back on the pixel grid. + cmd::keep_aligned_in(doc, &placed); dim_changed = cmd::dimension::refresh(&before, doc)?; Ok(v) }) { @@ -35,7 +39,9 @@ impl Session { Ok(shaper_changed) => { // Attached Shaper groups whose art was rebuilt go back on their path. if shaper_changed || dim_changed { - cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc)); + let doc = Arc::make_mut(&mut st.doc); + let placed = cmd::objects_on_path::refresh(&before, doc); + cmd::keep_aligned_in(doc, &placed); } // Symbol editing: the symbol follows its art on the editing layer, before // inline graphics follow their symbols. @@ -340,7 +346,7 @@ impl Session { preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0), ); st.keep_interaction(it); - if preview.0 == "object.transform" { + if preview.0 == "object.transform" && changed { let m = cmd::matrix_param(&preview.1, "matrix"); if let Some(m) = m { // The options are the last preview's params with the values it noted, without the diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 21822583d..5ad84885f 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,5 +1,6 @@ use super::{ - CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only, + CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, + strip_replay_only, }; impl Session { @@ -14,11 +15,14 @@ impl Session { r } - /// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or - /// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects. + /// Error, under the command's own id, at the first of `ids` that is malformed, else at the first + /// that names no node or names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on + /// objects. fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> { let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) }; let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) }; + // A malformed entry anywhere in the list is refused first, under the key and the entry. + cmd::ids_param(params, "ids", id)?; for value in given { let node = cmd::checked_id(self, value, id)?; if st.doc.node(node).is_some_and(|n| n.is_layer()) { @@ -35,6 +39,13 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; + let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id)); + let r = self.execute_spec(spec, id, params); + self.reading = outer; + r + } + + fn execute_spec(&mut self, spec: &'static CommandSpec, id: &str, params: &Value) -> Result { // A key the command's params description doesn't name is a parameter error (#1442), before // anything runs, for the commands other commands run too. A `command.batch` or an // `action.batch` checks its steps before the first one runs, and each step is checked again @@ -57,6 +68,13 @@ impl Session { if SELECTS_GIVEN_IDS.contains(&id) { self.check_given_ids(id, params)?; } + // An `ids` list with an entry that is not an object id, or an `ids` that is not a list, is + // refused under the command's id before the menu check, which would otherwise run on the + // other entries or on the selection. This holds with no document open too, where commands + // such as `effect.list` still run. + if cmd::params::takes_key(spec.params, "ids") { + cmd::ids_param(params, "ids", id)?; + } // Clear's menu needs a selection; an agent supplying explicit targets only needs a // document. The command validates those targets before making any edit. let given = given_ids(spec.params, params); @@ -139,6 +157,9 @@ impl Session { } if let Some(open) = open { self.open_undo_groups_in_new_documents(&open); + // Type is drawn in the composite fonts of the documents open now, an undone or opened + // document's included. + cmd::compositefonts::sync(self); } let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query)); if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 02f2bb532..27b9ad8ac 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -13,10 +13,17 @@ //! move or go away). Attaching the folder registers them again. //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. -//! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not +//! Attaching the folder installs them again, except in Safe Mode and except the ones the user +//! disabled, which `disabled.json` in the same folder lists (`plugin.setEnabled`; a window locks +//! `disabled.lock` while it writes the list). The Additional Plug-ins Folder preference is not //! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins //! stored there that list permissions (id, module hash, permissions); a stored plug-in whose //! permissions it doesn't grant is skipped with a log record, and stays in the folder. +//! * **Plug-in settings and caches**: `storage/id-/` in the plug-in store, the id written as +//! [`case_safe`] writes it, holds what a plug-in +//! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a +//! `cache` folder with one file per entry. The host names every file; a plug-in only names keys. +//! Replacing a plug-in keeps them, and `plugin.remove` deletes them. //! //! The files are untrusted input like any other: sizes and counts are capped, a file that does not //! parse is skipped with a log record, and every write is atomic. @@ -24,8 +31,12 @@ use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use vectorcraft_pdf::{PdfPreset, builtin_preset}; +use vectorcraft_plugins::safe_mode; +use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; use crate::Session; use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; @@ -44,10 +55,33 @@ const PROFILE_EXT: &str = "icc"; pub const MAX_PLUGIN_STORE: u64 = 512 << 20; /// The extension of a stored plug-in. const PLUGIN_EXT: &str = "wasm"; +/// The file in the plug-in folder that lists the plug-ins the user disabled. +pub const DISABLED_FILE: &str = "disabled.json"; +/// The file in the plug-in folder that a window locks while it reads and writes [`DISABLED_FILE`]. +pub const DISABLED_LOCK: &str = "disabled.lock"; /// The file in the plug-in store that holds the permissions the user granted. const GRANTS_FILE: &str = "grants.json"; /// The largest grants file read. const MAX_GRANTS_FILE: u64 = 1 << 20; +/// The folder in the plug-in store that holds each plug-in's settings and cache. +const PLUGIN_DATA_DIR: &str = "storage"; +/// A plug-in's settings file, in its folder. +const SETTINGS_FILE: &str = "settings.json"; +/// Where a settings file that doesn't read is moved, replacing the one moved there before. +const BAD_SETTINGS_FILE: &str = "settings.unreadable.json"; +/// The folder of a plug-in's cache entries, in its folder. +const CACHE_DIR: &str = "cache"; +/// The extension of a cache entry's file. +const CACHE_EXT: &str = "bin"; +/// Items in a cache folder a write looks at, at most: the entries, and temporary files a write left. +/// A write to a folder that holds more is refused as full. +pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; +/// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose +/// each other's settings. +static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); +/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder +/// until the plug-in is removed, which sets it and drops it. +static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -114,6 +148,25 @@ fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) } +/// [`DISABLED_LOCK`] in `dir`, locked until the file is dropped, waiting while another window holds +/// it; `None` when it can't be opened or locked. +#[cfg(not(target_arch = "wasm32"))] +fn lock_disabled(dir: &Path) -> Option { + let opened = std::fs::create_dir_all(dir) + .and_then(|()| std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(DISABLED_LOCK))); + let file = opened.and_then(|f| f.lock().map(|()| f)); + if let Err(e) = &file { + log::warn!("can't lock {}: {e}", dir.join(DISABLED_LOCK).display()); + } + file.ok() +} + +/// The browser has no folders to keep anything in. +#[cfg(target_arch = "wasm32")] +fn lock_disabled(_: &Path) -> Option { + None +} + /// Remove `dir/file`; a file that isn't there is what was wanted. fn take(dir: &Path, file: &str) { let path = dir.join(file); @@ -223,10 +276,26 @@ impl Session { } } - /// Keep the plug-ins this session installs in `dir`, and install the ones already there (a - /// module that doesn't load, and one that lists permissions the grants there don't grant it, - /// is skipped with a log record). + /// Keep the plug-ins this session installs in `dir`, take the disabled plug-ins saved there + /// ([`DISABLED_FILE`]), and install the stored ones ([`Session::load_stored_plugins`]). pub fn attach_plugin_store(&mut self, dir: PathBuf) { + let disabled = + read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); + safe_mode::set_disabled(disabled); + self.plugin_dir = Some(dir); + self.load_stored_plugins(); + } + + /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user + /// disabled (matched by file name, which is made from the id). The launch marker names each + /// file while it loads. A module that doesn't load, and one that lists permissions the grants + /// there don't grant it, is skipped with a log record. A grant covers only the module it was + /// given to ([`grant_covers`]). + pub fn load_stored_plugins(&mut self) { + let Some(dir) = self.plugin_dir.clone() else { return }; + if safe_mode::active() { + return; + } let grants = stored_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; @@ -235,35 +304,82 @@ impl Session { log::warn!("skipping the other stored plug-ins: they hold more than {MAX_PLUGIN_STORE} bytes together"); break; } + let item = safe_mode::Item::file(path.display().to_string()); + let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()); + let by_id = safe_mode::skipped_ids().iter().any(|id| stem.as_deref() == Some(file_stem(id).as_str())); + if safe_mode::skipped(&item) || by_id { + log::info!("skipping the stored plug-in {}: disabled", path.display()); + continue; + } + let token = safe_mode::loading(&item); let loaded = read_capped(&path, (limits.max_module_bytes as u64).min(left)).and_then(|bytes| { left -= bytes.len() as u64; vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string()) }); + safe_mode::loaded(token); match loaded { // Only this store's grants count: one given elsewhere in the process doesn't let a // module stored here load. Ok(plugin) if plugin.manifest().asks_permissions() => { let m = plugin.manifest(); - match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) { + match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } - None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()), + None => { + log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) + } } } Ok(plugin) => { + vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), } } - self.plugin_dir = Some(dir); + } + + /// Write the disabled plug-ins saved for the next launch to the plug-in folder → whether they + /// went there, every change included (`false` without a folder, and when a plug-in turned off + /// found the file's list full: [`safe_mode::Merged::complete`]). The changes this launch made are applied to the list + /// the file holds now ([`safe_mode::merge_saved`]), so a change another window saved to the same + /// folder meanwhile stays. Windows that save at the same time take turns on [`DISABLED_LOCK`]; + /// when it can't be locked, nothing is written, and the next change saves this one too. So is + /// a file that can't be read (too large, say) or that a later version wrote + /// ([`safe_mode::read_disabled_file`]): it is kept as it is. + pub fn store_disabled_plugins(&self) -> bool { + let Some(dir) = self.plugin_dir.as_deref() else { return false }; + let Some(_turn) = lock_disabled(dir) else { return false }; + let path = dir.join(DISABLED_FILE); + let theirs = match read_capped(&path, safe_mode::MAX_DISABLED_FILE_BYTES) { + Ok(bytes) => match safe_mode::read_disabled_file(&bytes) { + Some(items) => items, + None => { + log::warn!("can't save {}: it holds another format or version of the list", path.display()); + return false; + } + }, + Err(_) if !path.exists() => Vec::new(), + Err(e) => { + log::warn!("can't save {}: {e}", path.display()); + return false; + } + }; + let merged = safe_mode::merge_saved(Some(theirs)); + let written = put(dir, DISABLED_FILE, &merged.bytes); + if written { + safe_mode::saved_written(&merged); + } + written && merged.complete() } /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was - /// granted every one it lists, in the grants of the attached plug-in store when there is one, - /// else in this process's ([`vectorcraft_plugins::registry::approved`]). + /// granted every one it lists, in the grants of the attached plug-in store when there is one + /// (for this module alone, [`grant_covers`]), else in this process's + /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) } @@ -275,17 +391,22 @@ impl Session { move |plugin| { let m = plugin.manifest(); match &grants { - Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())), + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| grant_covers(g, plugin)), _ => vectorcraft_plugins::registry::approved(plugin), } } } - /// Write the module `bytes` of the plug-in `id` to the store. - pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) { - if let Some(dir) = &self.plugin_dir { - put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes); - } + /// Write the module `bytes` of the plug-in `id` to the store → whether they went there (`false` + /// without a folder). + pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) -> bool { + self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) + } + + /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it + /// (`None` without a plug-in folder). + pub(crate) fn stored_plugin_file(&self, id: &str) -> Option { + self.plugin_dir.as_ref().map(|dir| dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))).display().to_string()) } /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's @@ -334,6 +455,379 @@ impl Session { take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id))); } } + + /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without + /// one, nowhere (its storage calls then fail as unavailable). + pub(crate) fn attach_plugin_storage(&self, id: &str) { + vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); + } + + /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A + /// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls + /// can't reach it and removing the plug-in leaves it. + pub(crate) fn has_plugin_storage(&self, id: &str) -> bool { + self.plugin_dir + .as_deref() + .is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok()) + } + + /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. + /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it + /// as it was, when the rename does; what the deletion then leaves stays under that name, with a + /// log record, and is deleted at the plug-in's next removal. + pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> { + vectorcraft_plugins::registry::set_storage(id, None); + let Some(dir) = &self.plugin_dir else { return Ok(()) }; + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = plugin_data_dir(dir, id); + // Every storage handed out for the folder, including those runs still hold, is closed once + // the folder is out of the way; until then they keep working. + let close = || { + if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { + closed.store(true, Ordering::Release); + } + }; + // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link + // in place of the plug-in's own folder is removed itself (`remove_dir_all` doesn't follow it). + let data = dir.join(PLUGIN_DATA_DIR); + if is_link(&data) { + log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display()); + close(); + return Ok(()); + } + let mut aside = path.clone().into_os_string(); + aside.push(".removed"); + let aside = PathBuf::from(aside); + let _ = std::fs::remove_dir_all(&aside); + match std::fs::rename(&path, &aside) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))), + Ok(()) => {} + } + close(); + if std::fs::symlink_metadata(&aside).is_ok() + && let Err(e) = std::fs::remove_dir_all(&aside) + { + log::warn!("can't delete {}: {e}", aside.display()); + } + Ok(()) + } +} + +/// `name` (a plug-in id or a cache entry name, ASCII) written for a file name that no other name +/// gets on a file system that ignores case: `_` is written `__`, each capital letter `_` and the +/// letter in lower case, and `.` `_0`. +fn case_safe(name: &str) -> String { + let mut file = String::with_capacity(name.len() * 2); + for c in name.chars() { + match c { + '_' => file.push_str("__"), + '.' => file.push_str("_0"), + c if c.is_ascii_uppercase() => { + file.push('_'); + file.push(c.to_ascii_lowercase()); + } + c => file.push(c), + } + } + file +} + +/// The folder of plug-in `id`'s settings and cache in the plug-in store `dir`. The `id-` in front +/// keeps an id such as `con` from naming a device on Windows. +pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { + dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id))) +} + +/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. +fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { + let dir = dir?; + let data = plugin_data_dir(dir, id); + let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); + Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed })) +} + +/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs). +/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as +/// [`case_safe`] writes it, so that names differing only in case get different files. A link +/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a +/// plain file. +pub(crate) struct PluginStorage { + /// The plug-in store, and the plug-in's folder in it. + root: PathBuf, + dir: PathBuf, + /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the + /// folder ([`PLUGIN_DATA_OPEN`]). + closed: Arc, +} + +/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system. +fn sum_sizes(files: &[(PathBuf, u64)]) -> u64 { + files.iter().fold(0, |sum, (_, n)| sum.saturating_add(*n)) +} + +/// Is `name` the file name of a cache entry? Entry files end in `.bin` and never start with a `.` +/// ([`case_safe`] writes no `.`), while the temporary files of `write_atomic` do. +fn is_entry_file(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|n| !n.starts_with('.') && n.ends_with(&format!(".{CACHE_EXT}"))) +} + +/// What a storage call is told when the file system fails. +fn io(e: &std::io::Error) -> StorageError { + StorageError::Io(e.to_string()) +} + +/// Does grant `g` cover `plugin`: was it given to this module (the same SHA-256) for every +/// permission the module lists? A module with other bytes under the id needs the user's approval +/// again, and so doesn't get the settings and cache kept for the id without it, and so does every +/// module when the grant records no hash. +fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool { + !g.hash.is_empty() && g.hash == plugin.hash() && g.covers(&plugin.manifest().permissions()) +} + +/// Is `path` a link (a symbolic link or, on Windows, a junction)? +fn is_link(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || reparse_point(&m)) +} + +#[cfg(windows)] +fn reparse_point(m: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn reparse_point(_: &std::fs::Metadata) -> bool { + false +} + +impl PluginStorage { + fn settings_path(&self) -> PathBuf { + self.dir.join(SETTINGS_FILE) + } + + fn cache_dir(&self) -> PathBuf { + self.dir.join(CACHE_DIR) + } + + /// The file of the cache entry `name`. + fn entry_path(&self, name: &str) -> PathBuf { + self.cache_dir().join(format!("{}.{CACHE_EXT}", case_safe(name))) + } + + /// Checks that no folder between the store and `path`, nor `path` itself, is a link. + fn no_links(&self, path: &Path) -> Result<(), StorageError> { + let mut at = path; + while at != self.root { + if is_link(at) { + return Err(StorageError::Unavailable); + } + match at.parent() { + Some(up) => at = up, + None => break, + } + } + Ok(()) + } + + /// The settings, by key. A file that is too large or doesn't parse is moved aside + /// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the + /// file's size before it is read. + fn read_settings(&self, meter: &mut Meter) -> Result>, StorageError> { + let path = self.settings_path(); + self.no_links(&path)?; + let size = match std::fs::metadata(&path) { + Ok(m) => m.len(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(e) => return Err(io(&e)), + }; + let readable = size <= storage::MAX_SETTINGS_BYTES as u64; + if readable { + meter.take(size)?; + } + let parsed = readable + .then(|| read_capped(&path, storage::MAX_SETTINGS_BYTES as u64).ok()) + .flatten() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .and_then(|stored| { + let decode = + |(k, v): (String, String)| if storage::valid_name(&k) { vectorcraft_format::base64_decode(&v).map(|v| (k, v)) } else { None }; + let settings: Option>> = stored.into_iter().map(decode).collect(); + settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES)) + }); + match parsed { + Some(settings) => Ok(settings), + None => { + let aside = self.dir.join(BAD_SETTINGS_FILE); + log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display()); + std::fs::rename(&path, &aside).map_err(|e| io(&e))?; + Ok(BTreeMap::new()) + } + } + } + + /// Writes `settings` as the settings file, or removes it when there are none. `meter` is charged + /// the new file's size before it is written. + fn write_settings(&self, settings: &BTreeMap>, meter: &mut Meter) -> Result<(), StorageError> { + let path = self.settings_path(); + if settings.is_empty() { + return match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io(&e)), + _ => Ok(()), + }; + } + let stored: BTreeMap<&str, String> = settings.iter().map(|(k, v)| (k.as_str(), vectorcraft_format::base64_encode(v))).collect(); + let bytes = serde_json::to_vec(&stored).map_err(|e| StorageError::Io(e.to_string()))?; + if bytes.len() > storage::MAX_SETTINGS_BYTES { + return Err(StorageError::Full); + } + meter.take(bytes.len() as u64)?; + std::fs::create_dir_all(&self.dir).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, &bytes).map_err(|e| io(&e)) + } + + /// Fails with [`StorageError::Unavailable`] once the storage is closed. + fn open(&self) -> Result<(), StorageError> { + if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } + } + + /// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which + /// leaves out the temporary files of writes) and their sizes, and whether the folder holds more + /// than [`MAX_CACHE_SCAN`] items, in which case only the entries among the first that many are + /// listed. + fn cache_files(&self) -> Result<(Vec<(PathBuf, u64)>, bool), StorageError> { + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], false)), + Err(e) => return Err(io(&e)), + }; + let mut files = Vec::new(); + for (seen, e) in entries.enumerate() { + if seen == MAX_CACHE_SCAN { + return Ok((files, true)); + } + let e = e.map_err(|e| io(&e))?; + if is_entry_file(&e.file_name()) + && let Ok(m) = std::fs::symlink_metadata(e.path()) + && m.is_file() + && !reparse_point(&m) + { + files.push((e.path(), m.len())); + } + } + Ok((files, false)) + } +} + +impl Storage for PluginStorage { + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + Ok(self.read_settings(meter)?.remove(key)) + } + + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { + return Err(StorageError::Full); + } + settings.insert(key.to_string(), value.to_vec()); + self.write_settings(&settings, meter) + } + + fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if settings.remove(key).is_none() { + return Ok(false); + } + self.write_settings(&settings, meter).map(|()| true) + } + + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::symlink_metadata(&path) { + Ok(m) if m.is_file() && m.len() <= storage::MAX_CACHE_ENTRY as u64 => meter.take(m.len())?, + Ok(_) => return Err(StorageError::Unavailable), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(io(&e)), + } + read_capped(&path, storage::MAX_CACHE_ENTRY as u64).map(Some).map_err(StorageError::Io) + } + + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + let (files, overfull) = self.cache_files()?; + if overfull { + return Err(StorageError::Full); + } + let old = files.iter().find(|(p, _)| *p == path).map(|(_, n)| *n); + let total = sum_sizes(&files).saturating_sub(old.unwrap_or(0)).saturating_add(value.len() as u64); + let too_many = old.is_none() && files.len() >= storage::MAX_CACHE_ENTRIES; + if value.len() > storage::MAX_CACHE_ENTRY || total > storage::MAX_CACHE_BYTES || too_many { + return Err(StorageError::Full); + } + meter.take(value.len() as u64)?; + std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, value).map_err(|e| io(&e)) + } + + fn remove_cache_entry(&self, name: &str) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(io(&e)), + } + } + + fn clear_cache(&self) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(io(&e)), + }; + // Every plain file, one at a time as the folder is read, past the ones a write looks at too. + let mut freed = 0_u64; + for e in entries { + let path = e.map_err(|e| io(&e))?.path(); + if let Ok(m) = std::fs::symlink_metadata(&path) + && m.is_file() + && !reparse_point(&m) + { + std::fs::remove_file(&path).map_err(|e| io(&e))?; + freed = freed.saturating_add(m.len()); + } + } + Ok(freed) + } + + fn cache_bytes(&self) -> u64 { + if self.open().is_err() { + return 0; + } + self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0) + } } /// What the grants file of a plug-in store holds. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b9e247ed9..742d7a3ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4,11 +4,13 @@ use super::*; use vectorcraft_geom::Point; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; +mod actionset; pub(super) mod adjust; mod ai_open_path; mod appearance; mod areafit; mod assets; +mod assets_3d; mod attributes; mod bboxrotate; mod blendfidelity; @@ -31,6 +33,7 @@ mod colorize; pub(super) mod colormgmt; mod colorthemes; mod colourfonts; +mod compare; mod compound_shape; mod compoundpaint; mod constrain_new; @@ -122,6 +125,7 @@ mod persp_planes; pub(super) mod persp_select; mod persp_text; mod perspgrid; +mod pixelalign; mod place; mod placed_document; mod plugins; @@ -162,6 +166,7 @@ mod strokeux; mod stylelib; mod stylepanel; mod styles; +mod svgfilter; mod swatchcmds; mod swatches; mod swatchlib; diff --git a/crates/engine/src/tests/actionset.rs b/crates/engine/src/tests/actionset.rs new file mode 100644 index 000000000..d80e649ec --- /dev/null +++ b/crates/engine/src/tests/actionset.rs @@ -0,0 +1,184 @@ +//! Action sets another app saved as text (`actionSet.read`): actions, steps, the tokens left unread +//! and the caps. Every set here is written for the test, with invented names. + +use serde_json::json; + +use crate::cmd::actionset::{AiaStep, read_aia}; +use crate::*; + +/// `text` as a bracketed hexadecimal value. +fn hex(text: &str) -> String { + let digits: String = text.bytes().map(|b| format!("{b:02x}")).collect(); + format!("[ {} {digits} ]", text.len()) +} + +/// A set written from scratch in the layout the module describes: two actions, the second with a +/// step left out of playback, a step with recorded values of each form, and names in several +/// scripts. +fn sample() -> String { + format!( + "/version 3\n/name {set}\n/isOpen 1\n/actionCount 2\n/action-1 {{\n\t/name {a1}\n\t/keyIndex 0\n\t/eventCount 1\n\t/event-1 {{\n\t\t/internalName (sample_join)\n\t\t/localizedName {join}\n\t\t/isOn 1\n\t\t/parameterCount 3\n\t\t/parameter-1 {{\n\t\t\t/key 1\n\t\t\t/type (unit real)\n\t\t\t/value 2.5\n\t\t}}\n\t\t/parameter-2 {{\n\t\t\t/type (ustring)\n\t\t\t/value {txt}\n\t\t}}\n\t\t/parameter-3 {{\n\t\t\t/type (raw)\n\t\t\t/value < 3\n\t\t\t\t0a7d0b\n\t\t\t>\n\t\t\t/size 3\n\t\t}}\n\t}}\n}}\r\n/action-2 {{\r\n\t/name {a2}\r\n\t/event-1 {{\r\n\t\t/internalName (sample\\)one)\r\n\t\t/isOn 0\r\n\t\t/parameterCount 0\r\n\t}}\r\n\t/event-2 {{\r\n\t\t/internalName (sample_two)\r\n\t\t/localizedName {two}\r\n\t}}\r\n}}\r\n", + set = hex("Prep Sätze"), + a1 = hex("First"), + join = hex("Join"), + txt = hex("a } brace"), + a2 = hex("二番目"), + two = hex("Second Step"), + ) +} + +/// The set's name, its actions and their steps come in with the step's names, its on state and how +/// many values it was recorded with, and nothing is listed unread. +#[test] +fn a_set_reads_its_actions_and_steps() { + let set = read_aia(sample().as_bytes()).unwrap(); + assert_eq!((set.version, set.name.as_deref(), set.truncated), (3, Some("Prep Sätze"), false)); + assert!(set.unread.is_empty(), "{:?}", set.unread); + let names: Vec<&str> = set.actions.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["First", "二番目"]); + let step = |a: usize, s: usize| set.actions[a].steps[s].clone(); + let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, declared: Some(3), line: 9 }; + assert_eq!(step(0, 0), join); + assert_eq!((step(1, 0).name.as_str(), step(1, 0).label.clone(), step(1, 0).on), ("sample)one", None, Some(0))); + assert_eq!((step(1, 1).label.as_deref(), step(1, 1).on, step(1, 1).line), (Some("Second Step"), None, 39)); +} + +/// Every recorded value of a step is counted. A hexadecimal `/value` with the wrong byte count marks +/// the set truncated; a long one or one that isn't UTF-8 does not. +#[test] +fn recorded_values_are_counted_and_checked() { + let set = |values: &[String]| { + let params: String = values.iter().enumerate().map(|(i, v)| format!("/parameter-{} {{ /key 1 {v} }} ", i + 1)).collect(); + read_aia(format!("/version 3 /action-1 {{ /event-1 {{ {params} }} }}").as_bytes()).unwrap() + }; + let value = |t: &str| format!("/type (ustring) /value {}", hex(t)); + let read = set(&[value("one"), value("two"), value("three")]); + assert_eq!((read.actions[0].steps[0].params, read.truncated), (3, false)); + let read = set(&[value(&"n".repeat(2000)), "/value [ 1 ff ]".to_string(), "/type (real) /value 4.5".to_string()]); + assert!(!read.truncated, "a long value or one that isn't UTF-8 is not damage"); + assert!(set(&["/value [ 3 4142 ]".to_string()]).truncated); + assert!(set(&[value("one"), "/value [ 1 zz ]".to_string()]).truncated, "a second value that isn't hexadecimal"); +} + +/// `actionSet.read` takes exactly one of `path`, `data` and `dataBase64`, and text for the text +/// params; any other value is an error. +#[test] +fn read_command_refuses_bad_params() { + let mut s = Session::new(); + let text = "/version 3"; + let dir = std::env::temp_dir().join(format!("vc-actionset-params-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Mine.aia"); + std::fs::write(&file, text).unwrap(); + let path = file.to_string_lossy(); + assert!(s.execute("actionSet.read", &json!({ "path": path })).is_ok()); + let b64 = vectorcraft_format::base64_encode(text.as_bytes()); + for p in [ + json!({ "path": 5, "data": text }), + json!({ "data": 5 }), + json!({ "dataBase64": 5 }), + json!({ "data": text, "name": 7 }), + json!({ "path": path, "data": text }), + json!({ "path": path, "dataBase64": b64 }), + json!({ "data": text, "dataBase64": b64 }), + ] { + assert!(s.execute("actionSet.read", &p).is_err(), "{p}"); + } + let r = s.execute("actionSet.read", &json!({ "data": text, "name": null })).unwrap(); + assert_eq!(r["set"], "", "no set name and no file name"); + let _ = std::fs::remove_dir_all(dir); +} + +/// `actionSet.read` names the set after the file when it has no name, gives a step without a label, +/// or with a blank one, a null label, and refuses text that isn't an action set. +#[test] +fn read_command_answers_with_the_set() { + let mut s = Session::new(); + let text = sample().replacen(&format!("/name {}", hex("Prep Sätze")), "", 1); + let r = s.execute("actionSet.read", &json!({ "data": text, "name": "/sets/Mine.aia" })).unwrap(); + assert_eq!(r["set"], "Mine"); + assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "line": 34})); + assert_eq!(r["actions"][0]["steps"][0]["on"], true); + assert_eq!(r["actions"][0]["steps"][0]["label"], "Join"); + let blank = format!("/version 3 /action-1 {{ /event-1 {{ /internalName (x) /localizedName {} }} }}", hex(" ")); + assert_eq!(s.execute("actionSet.read", &json!({ "data": blank })).unwrap()["actions"][0]["steps"][0]["label"], json!(null)); + for text in ["/Menus { }", "", "version 3", "/version three"] { + assert!(s.execute("actionSet.read", &json!({ "data": text })).is_err(), "{text}"); + } + assert!(s.execute("actionSet.read", &json!({})).is_err()); +} + +/// `path` and `dataBase64` read the same set as `data`, and a set's name falls back to the path's. +#[test] +fn a_set_reads_from_a_path_and_from_base64() { + let dir = std::env::temp_dir().join(format!("vc-actionset-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Mine.aia"); + let text = "/version 3 /action-1 { /name [ 1 41 ] }"; + std::fs::write(&path, text).unwrap(); + let mut s = Session::new(); + let r = s.execute("actionSet.read", &json!({ "path": path.to_string_lossy() })).unwrap(); + assert_eq!((r["set"].as_str(), r["actions"][0]["name"].as_str()), (Some("Mine"), Some("A"))); + let b64 = vectorcraft_format::base64_encode(text.as_bytes()); + assert_eq!(s.execute("actionSet.read", &json!({ "dataBase64": b64, "name": "B.aia" })).unwrap()["set"], "B"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Tokens out of place are listed with their lines, and a cap marks the set truncated. +#[test] +fn stray_tokens_are_listed_and_caps_truncate() { + let set = read_aia(b"/version 3\n7 /name [ 1 zz ]\n/name [ 3 4142 ]\n/odd { /x 1 }\n}\n/action-1 { /name [ 1 41 ]").unwrap(); + let unread: Vec<&str> = set.unread.iter().map(|(_, t)| t.as_str()).collect(); + let bad = "[ … ] (not the hexadecimal bytes its count says)"; + assert_eq!(unread, ["7", bad, bad, "/odd {", "}", "blocks left open at the end: 1"]); + let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated; + assert!(short("/version 3 /actionCount 1 /odd { /action-1 { } }"), "the action in a block that isn't read"); + assert!(short("/version 3 /name [ 3 4142 ]"), "a set's name with the wrong byte count"); + assert!(short("/version 3 /action-1 { /name [ 1 zz ] }"), "an action's name that isn't hexadecimal"); + assert!(short("/version 3 /action-1 { /event-1 { /localizedName [ 3 4142 ] } }"), "a label with the wrong byte count"); + let cuts = ["/name [ 4 4142", "/name (ab", "/value < 2 0a", "/name", "/action-2 {"]; + let whole: Vec<&str> = cuts.into_iter().filter(|cut| !short(&format!("/version 3 /action-1 {{ }} {cut}"))).collect(); + assert!(whole.is_empty(), "{whole:?}"); + assert_eq!(set.name, None); + assert_eq!(set.actions[0].name, "A"); + let many = format!("/version 3 {}", "/action-1 { } ".repeat(1001)); + assert!(read_aia(many.as_bytes()).unwrap().truncated); + let deep = format!("/version 3 /action-1 {{ /event-1 {{ {} }} }}", "/parameter-1 { ".repeat(40)); + assert!(read_aia(deep.as_bytes()).unwrap().truncated); + let long = format!("/version 3 /name {}", hex(&"n".repeat(2000))); + assert!(read_aia(long.as_bytes()).unwrap().truncated); +} + +/// A count that differs from the actions, steps or recorded values that follow, or that isn't a +/// number that fits an `i64`, marks the set truncated. A count is read only in its own block. +#[test] +fn counts_that_differ_truncate() { + let short = |text: &str| read_aia(text.as_bytes()).unwrap().truncated; + assert!(short("/version 3 /actionCount 2 /action-1 { }"), "one action of two"); + assert!(short("/version 3 /action-1 { /eventCount 2 /event-1 { } }"), "one step of two"); + assert!(!short("/version 3 /actionCount 1 /action-1 { /eventCount 1 /event-1 { } }")); + assert!(short("/version 3 /actionCount 1 /action-1 { } /action-2 { }"), "two actions of one"); + assert!(short("/version 3 /action-1 { /eventCount 1 /event-1 { } /event-2 { } }"), "two steps of one"); + assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 1 } }"), "no recorded value of one"); + assert!(short("/version 3 /action-1 { /event-1 { /parameterCount 0 /parameter-1 { } } }"), "a recorded value of none"); + assert!(!short("/version 3 /action-1 { /event-1 { /parameterCount 1 /parameter-1 { } } }")); + assert!(short("/version 3 /actionCount -1"), "a negative count"); + assert!(short("/version 3 /action-1 { /eventCount -1 }"), "a negative step count"); + assert!(short("/version 3 /actionCount 99999999999999999999"), "a count too large for a number"); + for count in ["nope", "1.5", "(1)", "[ 1 31 ]", "< 1 31 >", "{ }"] { + assert!(short(&format!("/version 3 /actionCount {count}")), "a count that isn't a number: {count}"); + assert!(short(&format!("/version 3 /action-1 {{ /eventCount {count} }}")), "a step count: {count}"); + assert!(short(&format!("/version 3 /action-1 {{ /event-1 {{ /parameterCount {count} }} }}")), "{count}"); + } + assert!(short("/version 3 /actionCount /name [ 1 41 ]"), "a count followed by another field"); + assert!(short("/version 3 /action-1 { /eventCount }"), "a count at the end of its block"); + assert!(!short("/version 3 /action-1 { /event-1 { /parameter-1 { /actionCount nope } } }"), "a field inside a recorded value"); + assert!(!short("/version 3 /action-1 { /actionCount (x) }"), "a set's count in an action is not read"); +} + +#[test] +fn the_command_takes_only_the_keys_it_describes() { + // The control channel, MCP and the CLI check params against the description (#1442). + check_params("actionSet.read", &json!({"path": "/x/a.aia", "data": "", "dataBase64": "", "name": "a.aia"})).unwrap(); + assert!(check_params("actionSet.read", &json!({"pth": "/x/a.aia"})).is_err()); +} diff --git a/crates/engine/src/tests/assets_3d.rs b/crates/engine/src/tests/assets_3d.rs new file mode 100644 index 000000000..cdef5b1df --- /dev/null +++ b/crates/engine/src/tests/assets_3d.rs @@ -0,0 +1,220 @@ +//! 3D assets: Export 3D Object collects objects with a 3D effect as 3D assets, their Format picks +//! the mesh file (USDA by default, USDZ, glTF or OBJ), and `assets.export3D` writes them, apart from +//! the picture assets `assets.export` writes. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // Unstroked art: each object is one solid. + s.paint.stroke = Paint::None; + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A rectangle with Extrude & Bevel, named `name`. +fn extruded(s: &mut Session, x: f64, name: &str) -> u64 { + let id = run(s, "shape.rectangle", json!({"x": x, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [id]})); + run(s, "effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20.0}})); + run(s, "layer.setProps", json!({"id": id, "name": name})); + id +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// `(name, bytes)` of each file an export returned. +fn files(r: &Value) -> Vec<(String, Vec)> { + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap())) + .collect() +} + +#[test] +fn export_3d_object_collects_each_3d_object_as_a_usda_asset() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "Crate"); + run(&mut s, "select.set", json!({"ids": [a, b]})); + let before = undo_len(&s); + let r = run(&mut s, "assets.add3D", json!({})); + assert_eq!(r["added"], 2); + assert_eq!(undo_len(&s), before + 1, "one undo step for every object"); + let list = run(&mut s, "assets.list", json!({}))["assets"].as_array().unwrap().clone(); + assert_eq!( + list.iter().map(|a| (a["name"].clone(), a["format3D"].clone())).collect::>(), + [(json!("Box"), json!("usda")), (json!("Crate"), json!("usda"))] + ); + // Collected again: the same assets, and no undo step. + let again = run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert_eq!((again["added"].clone(), again["assets"][0].clone()), (json!(0), r["assets"][0].clone())); + assert_eq!(undo_len(&s), before + 1); + // Collect for Export of the same object makes a picture asset beside it. + assert_eq!(run(&mut s, "assets.add", json!({"ids": [a]}))["added"], 1); + // Undo takes the 3D assets back out in one step. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.assets.is_empty()); +} + +#[test] +fn export_3d_object_refuses_objects_without_a_3d_effect_and_bad_formats() { + let mut s = session(); + let plain = run(&mut s, "shape.rectangle", json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + let box3d = extruded(&mut s, 100.0, "Box"); + let before = undo_len(&s); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d, plain]})).is_err(), "one object without a 3D effect refuses them all"); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": "fbx"})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": 3})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [999_999]})).is_err()); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("assets.add3D", &json!({})).is_err(), "nothing selected"); + assert!(s.doc().unwrap().doc.assets.is_empty()); + assert_eq!(undo_len(&s), before); + run(&mut s, "select.set", json!({"ids": [box3d]})); + let r = run(&mut s, "assets.add3D", json!({"format": "gltf"})); + assert_eq!(run(&mut s, "assets.list", json!({}))["assets"][0]["format3D"], "gltf"); + assert_eq!(r["added"], 1); +} + +#[test] +fn the_format_is_set_per_asset_and_only_a_change_is_an_undo_step() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let before = undo_len(&s); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "usda"}))["changed"], 0); + assert_eq!(undo_len(&s), before, "no change, no undo step"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset], "format": "stl"})).is_err()); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset]})).is_err(), "the format is required"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [picture], "format": "obj"})).is_err(), "a picture asset has no 3D format"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [12345], "format": "obj"})).is_err()); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "obj"}))["changed"], 1); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().doc.asset(asset).unwrap().format_3d.as_deref(), Some("obj")); +} + +#[test] +fn export_3d_writes_each_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let cases = [("usda", vec!["Box.usda"]), ("usdz", vec!["Box.usdz"]), ("gltf", vec!["Box.gltf"]), ("obj", vec!["Box.obj", "Box.mtl"])]; + for (format, names) in cases { + run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": format})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), names, "{format}"); + assert!(out.iter().all(|(_, b)| !b.is_empty()), "{format}"); + } + // The OBJ holds the box: a rectangle's front and back and four walls, two triangles each. + let out = files(&run(&mut s, "assets.export3D", json!({}))); + let obj = String::from_utf8(out[0].1.clone()).unwrap(); + assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12); +} + +#[test] +fn export_3d_numbers_files_that_would_share_a_name_and_cleans_path_characters() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "box"); + let c = extruded(&mut s, 200.0, "../a:b"); + run(&mut s, "assets.add3D", json!({"ids": [a, b, c]})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.usda", "box-2.usda", "..-a-b.usda"]); +} + +#[test] +fn picture_and_3d_assets_export_apart() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let three_d = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + // assets.export leaves 3D assets to assets.export3D. + let e = s.execute("assets.export", &json!({"formats": [{"format": "png", "scale": "1x"}]})).unwrap_err().to_string(); + assert!(e.contains("no assets yet"), "{e}"); + let e = s.execute("assets.export", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "{e}"); + let e = s.execute("document.exportForScreens", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "Export for Screens refuses it too: {e}"); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let pngs = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}]}))); + assert_eq!(pngs.len(), 1); + assert!(s.execute("assets.export3D", &json!({"assets": [picture]})).is_err()); +} + +#[test] +fn export_3d_needs_3d_assets() { + let mut s = session(); + assert!(s.execute("assets.export3D", &json!({})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": [77]})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": "all"})).is_err()); +} + +#[test] +fn bad_3d_params_are_refused() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + assert!(s.execute("assets.add3D", &json!({"ids": [a], "format": null})).is_err(), "a given format must be a format"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert!(s.execute("assets.export3D", &json!({"folder": 123})).is_err()); + assert!(s.execute("assets.export3D", &json!({"zip": "true"})).is_err()); +} + +#[test] +fn an_asset_whose_object_lost_its_effect_is_refused_at_export() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "effect.remove", json!({"index": 0})); + let e = s.execute("assets.export3D", &json!({})).unwrap_err().to_string(); + assert!(e.contains("Box"), "{e}"); +} + +#[test] +fn export_3d_writes_into_a_folder_or_one_zip() { + let dir = std::env::temp_dir().join(format!("vc-assets-3d-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a], "format": "obj"}))["assets"][0].as_u64().unwrap(); + let r = run(&mut s, "assets.export3D", json!({"folder": folder})); + assert_eq!(r["files"], json!([format!("{folder}/Box.obj"), format!("{folder}/Box.mtl")])); + assert!(dir.join("Box.obj").is_file() && dir.join("Box.mtl").is_file()); + let zip = run(&mut s, "assets.export3D", json!({"assets": [asset], "zip": true})); + assert_eq!(zip["files"], json!(["Box.obj", "Box.mtl"])); + assert!(zip["name"].as_str().unwrap().ends_with("-3D.zip"), "{}", zip["name"]); + // A picture ZIP and a 3D ZIP in one folder are two files. + run(&mut s, "assets.add", json!({"ids": [a]})); + let pictures = run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}], "folder": folder, "zip": true})); + let meshes = run(&mut s, "assets.export3D", json!({"folder": folder, "zip": true})); + let (p, m) = (pictures["path"].as_str().unwrap(), meshes["path"].as_str().unwrap()); + assert_ne!(p, m); + assert!(std::path::Path::new(p).is_file() && std::path::Path::new(m).is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn three_d_assets_round_trip_through_the_native_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a], "format": "usdz"})); + let saved = s.doc().unwrap().doc.assets.clone(); + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "assets3d.vectorcraft", "dataBase64": b64})); + assert_eq!(s.doc().unwrap().doc.assets, saved); + assert_eq!(saved[0].format_3d.as_deref(), Some("usdz")); +} diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index b4be82c2e..f686d80dc 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3012,6 +3012,42 @@ fn a_trace_run_hands_its_trace_to_the_command() { assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}"); } +#[test] +fn image_trace_ignore_background_keeps_the_color_inside_the_art() { + // A black field, a light panel on it and a black ring on the panel. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0); + if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] } + }); + let traced = |background: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let black: Vec<_> = g + .children() + .unwrap() + .iter() + .filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0))) + .map(|n| outline(n).0.bounds().unwrap()) + .collect(); + (r["colors"].clone(), black) + }; + // The whole color: the ring goes with the field. + let (colors, black) = traced(false); + assert_eq!(colors, 1); + assert!(black.is_empty(), "{black:?}"); + // The background only: the ring stays, and nothing black reaches the image's edge. + let (colors, black) = traced(true); + assert_eq!(colors, 2); + assert_eq!(black.len(), 1, "{black:?}"); + let bb = black[0]; + assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}"); + // The params doc tells agents about the option. + assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false")); +} + /// A Document Library trace made away from its command no longer applies once the library's /// colours change: the settings' JSON leaves them out (#4605 review). #[test] @@ -3221,6 +3257,316 @@ fn image_trace_release_and_expand() { assert!(s.execute("imageTrace.release", &json!({})).is_err()); } +/// Expand by color: each color's paths in a group of their own, named by the color, the groups +/// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a +/// sublayer for each color. +#[test] +#[allow(clippy::cognitive_complexity)] // Exercises several color and stacking cases in one trace fixture. +fn image_trace_expand_by_color_groups_each_colors_paths() { + // A light field with a black ring and a red square on it. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 75.0); + if (34.0..=40.0).contains(&r) { + [0, 0, 0, 255] + } else if (130..170).contains(&x) && (55..95).contains(&y) { + [200, 40, 50, 255] + } else { + [240, 240, 220, 255] + } + }); + let names = |n: &Node| n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect::>(); + let hex = |n: &Node| n.appearance.fill_paint().color().unwrap().to_hex(); + let shot = |s: &Session| { + let img = render_doc(s, false); + (0..200).flat_map(|x| (0..150).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::>() + }; + for method in ["abutting", "overlapping"] { + let params = json!({"mode": "color", "palette": "limited", "colors": 3, "method": method}); + let expand = |by_color: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params, "byColor": by_color})).unwrap(); + let g = NodeId(r["id"].as_u64().unwrap()); + (s, g) + }; + let (flat, fg) = expand(false); + let (mut s, g) = expand(true); + let paths = node(&flat, fg).children().unwrap().clone(); + // Without byColor (the default) the paths sit in the trace's group, with no group of their own. + assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}"); + let (default, dg) = { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) + }; + assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given"); + // The colors in the order they first come in, bottom to top. + let mut order: Vec = vec![]; + for p in &paths { + if !order.contains(&hex(p)) { + order.push(hex(p)); + } + } + let mut sorted = order.clone(); + sorted.sort(); + assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}"); + let n = node(&s, g); + assert_eq!(names(&n), order, "{method}"); + // Each group holds its color's paths, and in turn they are the paths of the trace without + // byColor, in their order. + let groups = n.children().unwrap(); + for c in groups { + assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}"); + assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name); + } + let members: Vec> = groups.iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect(); + assert!(members == paths, "{method}"); + assert!(shot(&s) == shot(&flat), "{method}: the art changed"); + // Release to Layers: a sublayer for each color, in the same order. + let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap(); + let subs = ids(&r); + assert_eq!(subs.len(), 3, "{method}"); + for (sub, name) in subs.iter().zip(&order) { + let l = node(&s, *sub); + assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}"); + assert_eq!(names(&l), std::slice::from_ref(name), "{method}"); + } + } + + // imageTrace.expand takes it too, as one undo step. + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": params})).unwrap()["id"].as_u64().unwrap()); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + + // A trace of one color gives one group holding every path. + let mut one = session(); + let bar = + vectorcraft_trace::Raster::from_fn(80, 40, |x, y| if (10..70).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] }); + add_image(&mut one, &bar, Affine::IDENTITY); + let r = one.execute("imageTrace.makeAndExpand", &json!({"params": {"ignoreWhite": true}, "byColor": true})).unwrap(); + let og = node(&one, NodeId(r["id"].as_u64().unwrap())); + assert_eq!(names(&og), ["#000000"]); + let only = &og.children().unwrap()[0]; + assert!(!only.children().unwrap().is_empty() && only.children().unwrap().iter().all(|m| hex(m) == "#000000")); + + // Auto Grouping's groups are not split: refused, and the trace stays as it was. + let mut grouped = params.clone(); + grouped["autoGrouping"] = json!(true); + let e = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": grouped, "byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": grouped})).unwrap()["id"].as_u64().unwrap()); + let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + let e = s.execute("imageTrace.expand", &json!({"byColor": "yes"})).unwrap_err().to_string(); + assert!(e.contains("true or false"), "{e}"); + // Refused too when Auto Grouping grouped nothing, as with side-by-side stripes. + let mut s = session(); + let stripes = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| match x / 20 { + 0 => [200, 40, 40, 255], + 1 => [40, 160, 60, 255], + _ => [40, 70, 200, 255], + }); + add_image(&mut s, &stripes, Affine::IDENTITY); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap()); + assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. }))); + let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); + assert!(e.contains("Auto Grouping"), "{e}"); + assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); + + // A color linked to a global swatch goes by the swatch's name, a tint with its percentage. + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) { + (true, _) => [200, 40, 40, 255], + (false, true) => [40, 160, 60, 255], + (false, false) => [40, 70, 200, 255], + }); + add_image(&mut s, &r, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec<(u64, String)> = + node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect(); + let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect(); + colors.sort(); + colors.dedup(); + assert_eq!(colors, ["#2846c8", "#28a03c", "#c82828"]); + // The red paths link to Ink and the blue ones to Sky at 50%; green stays a plain color. + let pick = |want: &str| paths.iter().filter(|(_, h)| h == want).map(|(id, _)| *id).collect::>(); + s.execute("paint.setFill", &json!({"ids": pick("#c82828"), "swatch": "Ink"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": pick("#2846c8"), "swatch": "Sky", "tint": 50})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]); + // A swatch named as another color's #rrggbb still gets a group of its own. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let mut got = names(&node(&s, g)); + got.sort(); + assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]); + + // Gradient fills share one group, and a stroked line goes by its stroke. + let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| { + let t = (f64::from(x) + 0.5) / 100.0; + [(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255] + }); + let line = + vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] }); + for (art, params, want) in [ + (ramp, json!({"preset": "6 Colors", "params": {"gradients": true}, "byColor": true}), "Gradients"), + (line, json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}, "byColor": true}), "#000000"), + ] { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let res = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + assert_eq!(names(&node(&s, NodeId(res["id"].as_u64().unwrap()))), [want]); + } + + // Flat Logo stacks its shapes biggest first without cutting holes under white: a black ring's + // outline, a white disc on it and a black dot on that. Black keeps a group below white and one + // above it, and the art draws as before, through Make and Expand and through Expand. + let target = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| { + let r = (f64::from(x) + 0.5 - 60.0).hypot(f64::from(y) + 0.5 - 60.0); + if r < 15.0 || (30.0..50.0).contains(&r) { + [0, 0, 0, 255] + } else if r < 30.0 { + [255, 255, 255, 255] + } else { + [0, 0, 0, 0] + } + }); + let logo = json!({"mode": "logo", "logoColors": ["#000000"]}); + let traced = |by_color: bool, expand_after: bool| { + let mut s = session(); + add_image(&mut s, &target, Affine::IDENTITY); + let g = if expand_after { + let g = s.execute("imageTrace.make", &json!({"params": logo})).unwrap()["id"].as_u64().unwrap(); + s.execute("imageTrace.expand", &json!({"byColor": by_color})).unwrap(); + g + } else { + s.execute("imageTrace.makeAndExpand", &json!({"params": logo, "byColor": by_color})).unwrap()["id"].as_u64().unwrap() + }; + (s, NodeId(g)) + }; + for expand_after in [false, true] { + let (flat, fg) = traced(false, expand_after); + let (s, g) = traced(true, expand_after); + let paths = node(&flat, fg).children().unwrap().clone(); + assert_eq!(paths.iter().map(|p| hex(p)).collect::>(), ["#000000", "#ffffff", "#000000"]); + assert_eq!(names(&node(&s, g)), ["#000000", "#ffffff", "#000000"], "expand after: {expand_after}"); + let members: Vec> = node(&s, g).children().unwrap().iter().flat_map(|c| c.children().unwrap().iter().cloned()).collect(); + assert!(members == paths, "expand after: {expand_after}"); + assert!(shot(&s) == shot(&flat), "expand after: {expand_after}: the art changed"); + } + + // An Abutting trace's top path, given the bottom path's color and moved onto the middle one (by + // a move or by a Transform effect), moved clear of it with a drop shadow cast onto it, or moved + // to share a pixel at its edge, before Expand, still draws on top of it. + let stripes = vectorcraft_trace::Raster::from_fn(90, 30, |x, _| match x / 30 { + 0 => [200, 40, 40, 255], + 1 => [40, 160, 60, 255], + _ => [40, 70, 200, 255], + }); + let edited = |by_color: bool, how: &str| { + let mut s = session(); + add_image(&mut s, &stripes, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect(); + assert_eq!(paths.len(), 3); + let (first, middle, last) = (&paths[0], &paths[1], &paths[2]); + s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap(); + let (to, from) = (middle.visual_bounds().unwrap(), last.visual_bounds().unwrap()); + let (dx, dy) = (to.x0 - from.x0, to.y0 - from.y0); + match how { + "beside" => { + // The middle path and the moved top path end and start inside one pixel. + s.execute("object.move", &json!({"ids": [middle.id.0], "dx": -0.5, "dy": 0})).unwrap(); + s.execute("object.move", &json!({"ids": [last.id.0], "dx": -0.3, "dy": 0})).unwrap() + } + "move" => s.execute("object.move", &json!({"ids": [last.id.0], "dx": dx, "dy": dy})).unwrap(), + "transform" => { + s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "distort.transform", "params": {"moveH": dx, "moveV": dy}})).unwrap() + } + _ => { + s.execute("object.move", &json!({"ids": [last.id.0], "dx": 40, "dy": 0})).unwrap(); + s.execute("effect.apply", &json!({"ids": [last.id.0], "effect": "stylize.dropShadow", "params": {"x": -70, "y": 0, "blur": 0}})) + .unwrap() + } + }; + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": by_color})).unwrap(); + (s, g) + }; + for how in ["move", "transform", "shadow", "beside"] { + let (flat, _) = edited(false, how); + let (s, g) = edited(true, how); + assert_eq!(names(&node(&s, g)).len(), 3, "{how}"); + assert!(shot(&s) == shot(&flat), "{how}: the edited art changed"); + } + + // Two tints of one swatch that round to the same percentage are still two colors. + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + add_image(&mut s, &stripes, Affine::IDENTITY); + let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect(); + assert_eq!(paths.len(), 3); + s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let groups = node(&s, g).children().unwrap().clone(); + assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]); + assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::>(), [1, 2]); +} + +#[test] +fn image_trace_expand_by_color_preserves_live_pathfinder_art() { + let mut s = session(); + let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, y| { + if (10..40).contains(&y) && ((10..35).contains(&x) || (65..90).contains(&x)) { [0, 0, 0, 255] } else { [255, 255, 255, 255] } + }); + add_image(&mut s, &art, Affine::IDENTITY); + let g = NodeId(s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap()["id"].as_u64().unwrap()); + let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| c.path_data().is_some()).map(|c| c.id).collect(); + assert_eq!(paths.len(), 2); + s.execute("select.set", &json!({"ids": [paths[1].0]})).unwrap(); + s.execute("object.move", &json!({"dx": -45, "dy": 0})).unwrap(); + s.execute("effect.apply", &json!({"ids": [g.0], "effect": "pathfinder.subtract"})).unwrap(); + let before = render_doc(&s, false); + let before_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| before.pixel(x, y)).collect(); + let original = node(&s, g); + let steps = s.doc().unwrap().history.undo.len(); + let result = s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})); + if let Err(e) = &result { + assert!(e.to_string().contains("Pathfinder"), "{e}"); + assert_eq!(node(&s, g), original); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + } + let after = render_doc(&s, false); + let after_pixels: Vec<_> = (0..100).flat_map(|x| (0..60).map(move |y| (x, y))).map(|(x, y)| after.pixel(x, y)).collect(); + let changed = after_pixels.iter().zip(&before_pixels).filter(|(a, b)| a != b).count(); + assert_eq!(changed, 0, "byColor changed {changed} live Pathfinder pixels: {result:?}"); + assert!(result.is_err(), "byColor must refuse a live Pathfinder operand group"); + let error = s.execute("imageTrace.makeAndExpand", &json!({"id": g.0, "params": {"ignoreWhite": true}, "byColor": true})).unwrap_err(); + assert!(error.to_string().contains("Pathfinder"), "{error}"); + assert_eq!(node(&s, g), original); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + #[test] fn image_trace_make_and_expand_and_color_presets() { let mut s = session(); @@ -3825,6 +4171,43 @@ fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() { assert_eq!(r["colors"], 1); } +#[test] +fn image_trace_flat_logo_can_trace_only_the_named_colors() { + // A light disc in a black ring, a blue square on it, and a white spot. + let art = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let d = (fx - 60.0).hypot(fy - 60.0); + if (fx - 32.0).hypot(fy - 40.0) <= 6.0 { + [255, 255, 255, 255] + } else if (45..75).contains(&x) && (45..75).contains(&y) { + [40, 120, 240, 255] + } else if d <= 46.0 { + [240, 240, 220, 255] + } else if d <= 50.0 { + [0, 0, 0, 255] + } else { + [0, 0, 0, 0] + } + }); + let fills = |logo_white: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "logo", "logoColors": ["#000000", "#F0F0DC", "#2878F0"], "logoWhite": logo_white}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let mut hex: Vec = g.children().unwrap().iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect(); + hex.sort(); + hex.dedup(); + (r["colors"].clone(), hex) + }; + let (colors, hex) = fills(true); + assert_eq!(colors, 4); + assert!(hex.contains(&"#ffffff".to_string()), "{hex:?}"); + let (colors, hex) = fills(false); + assert_eq!(colors, 3); + assert_eq!(hex, ["#000000", "#2878f0", "#f0f0dc"]); +} + #[test] fn image_trace_object_remembers_its_settings() { let mut s = session(); diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 79b336df0..24d6d387d 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -278,6 +278,54 @@ fn gradient_swatches_come_along_with_their_stop_links() { assert_eq!(sw.gradient, g.gradient, "the added gradient swatch links its stop to the added Sky too"); } +/// A gradient swatch made from another one (by name, from a fill linked to it, or from a gradient +/// that names it, placed or not) holds the gradient alone, so copying art that uses it brings only +/// that swatch. A placed gradient keeps its aspect, and the swatch still fits each object it is +/// applied to. +#[test] +fn a_gradient_swatch_made_from_another_holds_no_link_to_it() { + let mut s = session(); + let glow = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#3080ff"}, {"offset": 1, "color": "#ffffff"}]}); + s.execute("swatch.new", &json!({"name": "Glow", "gradient": glow})).unwrap(); + let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Glow"})).unwrap(); + let Paint::Gradient(applied) = fill(&s, r) else { panic!("a gradient fill") }; + assert_eq!(applied.swatch.as_deref(), Some("Glow")); + let mut by_gradient = vectorcraft_tools::params::gradient_params(&applied); + by_gradient["swatch"] = json!("Glow"); + let mut placed = by_gradient.clone(); + placed["start"] = json!([25, 25]); + placed["end"] = json!([50, 25]); + placed["aspect"] = json!(50); + let made = [ + ("By Name", json!({"name": "By Name", "swatch": "Glow"})), + ("From Fill", json!({"name": "From Fill"})), + ("From Gradient", json!({"name": "From Gradient", "gradient": by_gradient})), + ("Placed", json!({"name": "Placed", "gradient": placed})), + ]; + let mut links = vec![]; + for (name, p) in &made { + s.execute("swatch.new", p).unwrap(); + let Paint::Gradient(sw) = doc(&s).swatch(name).unwrap().paint.clone() else { panic!("{name}: a gradient swatch") }; + assert_eq!(sw.gradient, applied.gradient, "{name}: Glow's gradient"); + links.push(sw.swatch.clone()); + } + assert_eq!(links, [None, None, None, None], "no swatch links to Glow"); + let Paint::Gradient(sw) = doc(&s).swatch("Placed").unwrap().paint.clone() else { panic!("a gradient swatch") }; + assert!(sw.geom.is_some_and(|g| (g.aspect - 0.5).abs() < 1e-9), "the placed gradient keeps its aspect: {:?}", sw.geom); + for (name, _) in &made { + let t = rect(&mut s, 100.0, 0.0, 100.0, 60.0); + s.execute("paint.setFill", &json!({"ids": [t.0], "swatch": name})).unwrap(); + if *name == "Placed" { + let Paint::Gradient(g) = fill(&s, t) else { panic!("a gradient fill") }; + let geom = g.geom.unwrap(); + assert!(geom.start.distance(Point::new(150.0, 30.0)) < 1e-9 && (geom.aspect - 0.5).abs() < 1e-9, "{geom:?}"); + } + copy(&mut s, &[t]); + assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::>(), [*name], "Glow stays behind"); + } +} + #[test] fn lab_spot_colours_show_as_the_target_documents_spot_option_says() { let mut s = session(); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index f841efedc..d0901f30a 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -374,7 +374,7 @@ fn rgb_art_separates_with_gcr() { RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() }, ) .pixel(50, 50); - assert!(k[0] < 20, "RGB black is mostly K: {k:?}"); + assert!((20..=30).contains(&k[0]), "RGB black carries 90% black: {k:?}"); let c = render( &doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() }, diff --git a/crates/engine/src/tests/compare.rs b/crates/engine/src/tests/compare.rs new file mode 100644 index 000000000..3bd4446d6 --- /dev/null +++ b/crates/engine/src/tests/compare.rs @@ -0,0 +1,276 @@ +//! `document.compare`: an artboard rendered at an image's size, as a PNG export draws it, and +//! measured against the image (RMSE, the pixels over a threshold, a difference image), with the +//! image and the render flattened on one background; images of other proportions, files that +//! can't be read and paths outside the automation roots are errors. + +use serde_json::{Value, json}; + +use crate::Session; + +/// A 40 × 20 pt document with a black 10 × 10 pt square at (10, 5), no stroke. +fn session() -> (Session, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap()["id"].clone(); + (s, id) +} + +fn path_in(dir: &std::path::Path, name: &str) -> String { + dir.join(name).to_string_lossy().into_owned() +} + +/// The artboard exported as a PNG at 2 pixels a point on white. +fn export(s: &mut Session, path: &str) { + s.execute("document.export", &json!({"path": path, "format": "png", "scale": 2, "background": "white"})).unwrap(); +} + +fn compare(s: &mut Session, p: Value) -> Value { + s.execute("document.compare", &p).unwrap_or_else(|e| panic!("document.compare {p}: {e}")) +} + +fn error(s: &mut Session, p: Value) -> String { + s.execute("document.compare", &p).map(|v| panic!("document.compare {p} answered {v}")).unwrap_err().to_string() +} + +#[test] +fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() { + let dir = vectorcraft_testkit::temp_dir("compare-moved"); + let png = path_in(&dir, "art.png"); + let (mut s, id) = session(); + export(&mut s, &png); + let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()); + let r = compare(&mut s, json!({"path": png})); + assert_eq!(r, json!({"width": 80, "height": 40, "rmse": 0.0, "over": 0, "total": 3200, "threshold": 0.08})); + assert_eq!((s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()), before, "comparing doesn't edit the document"); + // 4 pt is 8 pixels: 8 columns of the square's 20 rows turn white and 8 turn black, every + // channel by 255. + s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap(); + let r = compare(&mut s, json!({"path": png})); + let over = 2 * 8 * 20; + assert_eq!((r["over"].as_u64(), r["total"].as_u64()), (Some(over), Some(3200)), "{r}"); + let rmse = ((over as f64 / 3200.0).sqrt() * 1e6).round() / 1e6; + assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}"); + // Above every difference, nothing is over. + assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_image_and_the_render_are_flattened_on_the_background() { + let dir = vectorcraft_testkit::temp_dir("compare-background"); + let png = path_in(&dir, "clear.png"); + // Transparent everywhere, with red in the color channels that the alpha hides. + image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 20})).unwrap(); + // Nothing drawn: both are the background alone. + for background in ["#808080", "#ffffff"] { + let r = compare(&mut s, json!({"path": png, "background": background})); + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(0.0), Some(0)), "{background}: {r}"); + } + // A black left half: 128 from gray in every channel of half the pixels, 255 from white. + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + let r = compare(&mut s, json!({"path": png, "background": "#808080"})); + let rmse = |d: f64| ((0.5f64).sqrt() * d / 255.0 * 1e6).round() / 1e6; + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(128.0)), Some(1600)), "{r}"); + let r = compare(&mut s, json!({"path": png})); + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}"); + // A threshold just above 128/255 leaves the gray differences out. + assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0)); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() { + let dir = vectorcraft_testkit::temp_dir("compare-size"); + let (mut s, _) = session(); + let square = path_in(&dir, "square.png"); + image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap(); + let e = error(&mut s, json!({"path": square})); + assert!(e.contains("30 × 30 pixels") && e.contains("30 × 15 pixels"), "{e}"); + let taller = path_in(&dir, "taller.png"); + image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap(); + let r = compare(&mut s, json!({"path": taller})); + assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_export_whose_height_was_rounded_compares_equal() { + let dir = vectorcraft_testkit::temp_dir("compare-rounded"); + let png = path_in(&dir, "art.png"); + // 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 20.25})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 5, "width": 10, "height": 10})).unwrap(); + export(&mut s, &png); + let r = compare(&mut s, json!({"path": png})); + assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08})); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() { + use crate::tests_links::{BLUE, Folder, RED, open, place, png, save, session, write}; + let dir = Folder::new("compare-linked"); + let pic = dir.file("photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &pic); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // The file changes after the save: the reopened document shows the red preview saved for it, + // and a PNG export reads the blue file. + write(&pic, &png(600, 300, BLUE)); + open(&mut s, &doc_path); + let out = dir.file("export.png"); + s.execute("document.export", &json!({"path": out, "format": "png", "background": "white"})).unwrap(); + let r = compare(&mut s, json!({"path": out})); + assert_eq!((r["rmse"].as_f64(), r["over"].as_u64(), r.get("warnings")), (Some(0.0), Some(0), None), "{r}"); + // Without the file the preview is drawn, and the reply says so. + std::fs::remove_file(&pic).unwrap(); + let r = compare(&mut s, json!({"path": out})); + assert_eq!(r["over"].as_u64(), r["total"].as_u64(), "{r}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("1 linked image output as its preview"), "{r}"); + // With type that doesn't fit its frame as well, the warnings come in the export's order. + let overset = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}})).unwrap(); + assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}"); + let exported = s.execute("document.export", &json!({"path": dir.file("now.png"), "format": "png", "background": "white"})).unwrap(); + let r = compare(&mut s, json!({"path": out})); + assert_eq!(r["warnings"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!(r["warnings"], exported["warnings"], "{r}"); +} + +#[test] +fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() { + let dir = vectorcraft_testkit::temp_dir("compare-warnings"); + let png = path_in(&dir, "art.png"); + let (mut s, _) = session(); + export(&mut s, &png); + assert_eq!(compare(&mut s, json!({"path": png})).get("warnings"), None); + crate::tests_menucmds::place_undecodable_png(&mut s); + s.execute("text.create", &json!({"x": 2, "y": 18, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let overset = s.execute("text.create", &json!({"x": 22, "y": 2, "text": "Ab\nCd\nEf", "size": 6, "area": {"width": 15, "height": 8}})).unwrap(); + assert_eq!(overset["overflow"], json!([overset["id"]]), "{overset}"); + let exported = s.execute("document.export", &json!({"path": path_in(&dir, "now.png"), "format": "png", "scale": 2})).unwrap(); + let r = compare(&mut s, json!({"path": png})); + assert_eq!(r["warnings"], exported["warnings"], "{r}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}"); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}"); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn diff_path_writes_each_pixels_differences() { + let dir = vectorcraft_testkit::temp_dir("compare-diff"); + let png = path_in(&dir, "art.png"); + let diff = path_in(&dir, "diff.png"); + let (mut s, id) = session(); + export(&mut s, &png); + s.execute("object.move", &json!({"ids": [id], "dx": 4, "dy": 0})).unwrap(); + let r = compare(&mut s, json!({"path": png, "diffPath": diff})); + assert_eq!(r["diffPath"], json!(diff)); + let img = image::open(&diff).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (80, 40)); + // The square spanned pixels 20–39 and now spans 28–47, rows 10–29. + for (x, y, want) in [(24, 20, 255), (44, 20, 255), (30, 20, 0), (24, 5, 0), (60, 20, 0)] { + assert_eq!(img.get_pixel(x, y).0, [want, want, want, 255], "({x}, {y})"); + } + let changed = img.pixels().filter(|p| p.0[0] != 0).count(); + assert_eq!(changed as u64, r["over"].as_u64().unwrap()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_unreadable_and_malformed_inputs_are_errors() { + let dir = vectorcraft_testkit::temp_dir("compare-errors"); + let (mut s, _) = session(); + let png = path_in(&dir, "art.png"); + export(&mut s, &png); + let missing = path_in(&dir, "missing.png"); + assert!(error(&mut s, json!({"path": missing})).contains(&missing)); + let text = path_in(&dir, "notes.png"); + std::fs::write(&text, "not an image\n").unwrap(); + assert!(error(&mut s, json!({"path": text})).contains("not a PNG, JPEG, WebP, TIFF or BMP image")); + let gif = path_in(&dir, "art.gif"); + image::RgbaImage::from_pixel(80, 40, image::Rgba([255; 4])).save(&gif).unwrap(); + assert!(error(&mut s, json!({"path": gif})).contains("not a PNG, JPEG, WebP, TIFF or BMP image")); + for (p, want) in [ + (json!({}), "missing `path`"), + (json!({"path": png, "artboard": 3}), "no artboard 3"), + (json!({"path": png, "threshold": 2}), "`threshold` must be a number from 0 to 1"), + (json!({"path": png, "threshold": "low"}), "`threshold` must be a number from 0 to 1"), + (json!({"path": png, "background": "transparent"}), "a color such as"), + (json!({"path": png, "diffPath": 5}), "`diffPath` must be a string"), + ] { + let e = error(&mut s, p.clone()); + assert!(e.contains(want), "{p}: {e}"); + } + let _ = std::fs::remove_dir_all(dir); +} + +/// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip +/// each, times 5 planes) is refused as too large to draw, without a panic. +#[test] +fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() { + let dir = vectorcraft_testkit::temp_dir("compare-strips"); + let (mut s, _) = session(); + let plane = |v: u16| vec![v; 6]; + let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)]; + let t = vectorcraft_testkit::tiff::Tiff { + width: 3, + height: 2, + photometric: 5, + bits: 8, + extra: &[0], + planar: true, + ppi: Some(150), + channels: &channels, + }; + let mut file = vectorcraft_testkit::tiff::tiff(&t); + // ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the + // eighth, at offset 102). + file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes()); + file[102..106].copy_from_slice(&1u32.to_le_bytes()); + assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1))); + let tif = path_in(&dir, "strips.tif"); + std::fs::write(&tif, &file).unwrap(); + let e = error(&mut s, json!({"path": tif})); + assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_image_and_the_difference_image_stay_inside_the_automation_roots() { + let dir = vectorcraft_testkit::temp_dir("compare-roots"); + for d in ["in", "out", "elsewhere"] { + std::fs::create_dir_all(dir.join(d)).unwrap(); + } + let (mut s, _) = session(); + let inside = path_in(&dir.join("in"), "art.png"); + let outside = path_in(&dir.join("elsewhere"), "art.png"); + export(&mut s, &inside); + export(&mut s, &outside); + let roots = crate::file_access::AutomationRoots::new(&[&dir.join("in")], &[&dir.join("out")]).unwrap().unwrap(); + let mut run = |p: Value| crate::file_access::confine(Some(&roots), || s.execute("document.compare", &p)); + assert!(run(json!({"path": inside})).is_ok()); + let e = run(json!({"path": outside})).unwrap_err().to_string(); + assert!(e.contains("outside the read root"), "{e}"); + let stray = path_in(&dir.join("elsewhere"), "diff.png"); + let e = run(json!({"path": inside, "diffPath": stray})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(!std::path::Path::new(&stray).exists()); + let kept = path_in(&dir.join("out"), "diff.png"); + assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept)); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/tests/docsetup.rs b/crates/engine/src/tests/docsetup.rs index c1c70ac07..cf2e4a510 100644 --- a/crates/engine/src/tests/docsetup.rs +++ b/crates/engine/src/tests/docsetup.rs @@ -28,16 +28,28 @@ fn create_text(s: &mut Session, t: &str) -> NodeId { NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) } -/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object. +/// Click with the Type tool at (x, y) and type `keys` one at a time on the keyboard; returns the +/// new text object. fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId { + enter_at(s, x, y, keys, true) +} + +/// Click with the Type tool at (x, y) and enter `texts` one at a time, typed on the keyboard +/// ([`Session::tool_typed_text`]) or inserted as the Glyphs panel and pastes do +/// ([`Session::tool_text`]); returns the new text object. +fn enter_at(s: &mut Session, x: f64, y: f64, texts: &[&str], keyboard: bool) -> NodeId { let v = ViewInfo::default(); s.select_tool("type", v).unwrap(); for k in [PointerKind::Down, PointerKind::Up] { s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); } let id = s.doc().unwrap().selection.objects[0]; - for k in keys { - s.tool_text(k, v).unwrap(); + for t in texts { + if keyboard { + s.tool_typed_text(t, v).unwrap(); + } else { + s.tool_text(t, v).unwrap(); + } } s.tool_key(ToolKey::Escape, Default::default(), v).unwrap(); id @@ -177,6 +189,53 @@ fn typographers_quotes_setting_changes_typed_quotes() { assert_eq!(text(&s, id).plain_text(), "\"b'"); } +/// A lone typed `”` is set like a straight quote where a quote opens: the macOS Serbian Cyrillic +/// layout has no straight quote and types `”` on Shift+2 (#5247), so Serbian text still gets +/// „…”. Where a quote closes it stays `”`. `“ „` and the single quotes stay as typed, so `’90s` +/// keeps its apostrophe. Pasted text and the Glyphs panel keep their quotes, and with the option +/// off `”` stays as typed. +#[test] +fn a_typed_closing_double_quote_opens_where_a_quote_opens() { + let mut s = session(); + s.execute("document.setup", &json!({"language": "Serbian: Cyrillic"})).unwrap(); + let id = type_at(&mut s, 50.0, 50.0, &["”", "П", "р", "о", "б", "а", "”"]); + assert_eq!(text(&s, id).plain_text(), "„Проба”"); + // The other curly quotes stay as typed. + let id = type_at(&mut s, 50.0, 100.0, &["„", "a", " ", "’", "b", "’", "“"]); + assert_eq!(text(&s, id).plain_text(), "„a ’b’“"); + // In English a `”` that opens is `“`; the apostrophes stay. + s.execute("document.setup", &json!({"language": "English: USA"})).unwrap(); + let id = type_at(&mut s, 50.0, 150.0, &["”", "i", "t", "’", "s", "”"]); + assert_eq!(text(&s, id).plain_text(), "“it’s”"); + // An apostrophe after a space stays an apostrophe, and a typed `‘` stays `‘`. + let id = type_at(&mut s, 50.0, 200.0, &["t", "h", "e", " ", "’", "9", "0", "s", " ", "‘", "a"]); + assert_eq!(text(&s, id).plain_text(), "the ’90s ‘a"); + // Where a quote closes, `”` stays `”` even if the document's closing quote is another. + s.execute("document.setup", &json!({"language": "German"})).unwrap(); + let id = type_at(&mut s, 50.0, 250.0, &["”", "a", "”"]); + assert_eq!(text(&s, id).plain_text(), "„a”"); + // More than one character is pasted text, not a typed quote. + let id = type_at(&mut s, 50.0, 300.0, &["”a”"]); + assert_eq!(text(&s, id).plain_text(), "”a”"); + // An IME commit of a lone `”` that will open is not verbatim (it is rewritten); from the Glyphs + // panel it would be inserted as it is. + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 50.0, 312.0), v).unwrap(); + } + assert!(!s.tool_typed_text_is_verbatim("”", v)); + assert!(s.tool_text_is_verbatim("”", v)); + s.tool_key(ToolKey::Escape, Default::default(), v).unwrap(); + // A `”` inserted from the Glyphs panel or pasted alone stays as inserted. + let id = enter_at(&mut s, 50.0, 325.0, &["”", "a"], false); + assert_eq!(text(&s, id).plain_text(), "”a"); + // With the option off a typed `”` stays as typed. + s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap(); + let id = type_at(&mut s, 50.0, 350.0, &["”", "b", "”"]); + assert_eq!(text(&s, id).plain_text(), "”b”"); +} + #[test] fn smart_punctuation_uses_the_document_quotes() { let mut s = session(); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 1af4ac40f..f6902e7a9 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2673,6 +2673,87 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() { assert_eq!(s.tool_id(), "rectangle"); } +/// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are +/// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected +/// while drawing is not, Copy taking the whole path (#2122), nor after a Cmd gesture that picks +/// nothing. +#[test] +fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { + let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() }); + let v = view(); + let mut s = session(); + s.select_tool("pen", v).unwrap(); + for x in [100.0, 200.0, 300.0, 400.0] { + gesture(&mut s, &[(x, 300.0)], none); + } + let id = s.doc().unwrap().selection.objects[0]; + let pasted = |s: &mut Session| { + let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone(); + let ids: Vec = ids.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect(); + ids.iter().map(|i| path(s, *i).subpaths[0].anchors.len()).collect::>() + }; + // A Cmd marquee around the middle two anchors: Copy takes them and the segment between. + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + assert_eq!(s.tool_id(), "pen"); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [2]); + // Cut takes the segment between them out of the path, as with Direct Selection chosen. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::>(); + assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2])); + // Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + gesture(&mut s, &[(500.0, 300.0)], none); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [5]); + // A Cmd+Shift marquee over nothing picks nothing: the Pen's last anchor stays its own. + s.execute("edit.undo", &json!({})).unwrap(); + let shift_cmd = Mods { shift: true, ..cmd }; + gesture(&mut s, &[(600.0, 500.0), (700.0, 600.0)], shift_cmd); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [5]); + // The tool started afresh in the same document (another view of it) keeps a pick picked. + s.execute("edit.undo", &json!({})).unwrap(); + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + s.reset_tool(); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [2]); +} + +/// #6123: with the other tools, Cmd (Ctrl) lends the selection tool chosen last. Direct Selection +/// lent picks anchors, which Copy takes, as with the Curvature tool, which Cmd lends Direct +/// Selection until a selection tool is chosen; the Selection tool lent selects the path whole. +#[test] +fn cmd_selected_anchors_with_other_tools_are_what_copy_takes() { + let cmd = Mods { cmd: true, ..Mods::default() }; + let v = view(); + let copied = |tool: &str, last_selection: Option<&str>| { + let mut s = session(); + let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300}, {"x": 300, "y": 300}, {"x": 400, "y": 300}]); + s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + if let Some(t) = last_selection { + s.select_tool(t, v).unwrap(); + } + s.select_tool(tool, v).unwrap(); + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + assert_eq!(s.tool_id(), tool); + s.execute("edit.copy", &json!({})).unwrap(); + let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone(); + let id = NodeId(ids[0].as_u64().unwrap()); + path(&s, id).subpaths[0].anchors.len() + }; + assert_eq!(copied("rectangle", Some("directSelection")), 2); + assert_eq!(copied("ellipse", Some("directSelection")), 2); + assert_eq!(copied("curvature", None), 2); + assert_eq!(copied("rectangle", None), 4, "the Selection tool lent selects the path whole"); +} + /// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn /// moves on its own and the Pen goes on drawing that path from it; another path's straight segment /// moves both its ends and a curved one bends; each drag is one undo step and the pointer is the diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 1c46d5302..b946f7192 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -1,10 +1,15 @@ //! Dynamic symbols (#4938): an instance changes the appearance of objects in its symbol's art and -//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. +//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them. Direct +//! Selection picks one path, compound path or type object of an instance's art, and a solid color +//! Fill or Stroke changes it. use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use crate::tooling::{UiRequest, ViewInfo}; use crate::*; fn session() -> Session { @@ -46,6 +51,9 @@ fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { const RED: [u8; 4] = [255, 0, 0, 255]; const BLUE: [u8; 4] = [0, 0, 255, 255]; +/// The refusal of anything but a solid fill or stroke on a picked object says so. +const REFUSED: &str = "solid color fill and stroke"; + #[test] fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { let mut s = session(); @@ -58,6 +66,10 @@ fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() { assert_eq!(pixel(&s, 80, 30), RED, "the changed object"); assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); assert_eq!(overrides(&s, other), 0, "other instances keep the symbol's look"); + // The same value again records nothing. + let undo = undo_steps(&s); + assert_eq!(s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap()["overrides"], 1); + assert_eq!(undo_steps(&s), undo, "no undo step"); // A later change to the same object merges with the first; null takes one back. s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": 50})).unwrap(); assert_eq!(node(&s, inst).extras().symbol_overrides[0].opacity, Some(0.5)); @@ -96,6 +108,314 @@ fn static_symbols_and_bad_targets_are_refused() { assert_eq!(overrides(&s, inst), 0); } +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The frontmost object of the active layer. +fn top(s: &Session) -> NodeId { + let d = &s.doc().unwrap().doc; + d.node(d.default_layer().unwrap()).unwrap().children().unwrap().last().unwrap().id +} + +/// Every pixel of the page. +fn page(s: &Session) -> Vec<[u8; 4]> { + let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true); + (0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect() +} + +/// A dynamic symbol "Art" of the frontmost object, made in place: its instance. +fn art_of_top(s: &mut Session) -> NodeId { + let t = top(s); + id(&s.execute("symbol.new", &json!({"name": "Art", "ids": [t.0], "type": "dynamic"})).unwrap()) +} + +/// Put a red fill and stroke on the root of instance `inst`'s art directly, past the command. +fn paint_root_red(s: &mut Session, inst: NodeId) { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(red.clone()), stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); +} + +/// A 30×30 red image placed with its frame from (50, 20) to (80, 50). +fn place_image(s: &mut Session) { + let png = crate::tests_links::png(30, 30, [255, 0, 0]); + let data = vectorcraft_format::base64_encode(&png); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": data, "at": [65, 35], "link": false})).unwrap(); +} + +fn select_top(s: &mut Session, n: usize) { + let d = &s.doc().unwrap().doc; + let kids = d.node(d.default_layer().unwrap()).unwrap().children().unwrap(); + let ids: Vec = kids[kids.len() - n..].iter().map(|c| c.id.0).collect(); + s.execute("select.set", &json!({ "ids": ids })).unwrap(); +} + +#[test] +fn objects_that_draw_no_fill_or_stroke_of_their_own_refuse_one() { + let folder = crate::tests_links::Folder::new("dynamic-symbol-kinds"); + let source = folder.file("placed.vectorcraft"); + let mut doc = vectorcraft_doc::Document::new(40.0, 40.0); + let layer = doc.layers[0].id; + let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let rect = vectorcraft_doc::Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), look); + doc.insert(Some(layer), 0, rect).unwrap(); + crate::tests_links::write(&source, &vectorcraft_format::save_file(&doc)); + type Make = Box; + let cases: Vec<(&str, &str, Make)> = vec![ + ( + "guide", + "a guide", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("view.guides.make", &json!({})).unwrap(); + }), + ), + ( + "blend", + "a blend", + Box::new(|s| { + let (a, b) = (square(s, 10.0), square(s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + }), + ), + ( + "envelope", + "an envelope", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap(); + }), + ), + ("radial Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.radial"))), + ("grid Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.grid"))), + ("mirror Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.mirror"))), + ( + "Intertwine", + "an Intertwine group", + Box::new(|s| { + square(s, 10.0); + square(s, 30.0); + select_top(s, 2); + s.execute("object.intertwine.make", &json!({})).unwrap(); + }), + ), + ( + "gradient mesh", + "a gradient mesh", + Box::new(|s| { + let r = square(s, 10.0); + s.execute("object.mesh.create", &json!({"ids": [r.0], "rows": 1, "cols": 1})).unwrap(); + }), + ), + ("image", "an image", Box::new(place_image)), + ( + "placed document", + "a placed document", + Box::new(move |s| { + let r = s.execute("file.place", &json!({"path": source, "at": [40, 40]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + }), + ), + ]; + // Every case runs, and the failure lists each object that fails. + let mut failures = Vec::new(); + for (label, kind, make) in cases { + let mut s = session(); + make(&mut s); + let inst = art_of_top(&mut s); + if let Err(why) = refuses_own_paint(&mut s, inst, kind) { + failures.push(format!("{label}: {why}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// Check that the root of instance `inst`'s art, an object of `kind` that draws no fill or stroke +/// of its own, refuses both, takes null, opacity and blend, and draws no stored fill or stroke. +fn refuses_own_paint(s: &mut Session, inst: NodeId, kind: &str) -> std::result::Result<(), String> { + let set = |s: &mut Session, p: &Value| s.execute("symbol.instance.setAppearance", p); + let undo = undo_len(s); + for key in ["fill", "stroke"] { + for paint in [json!("#ff0000"), json!({"none": true})] { + // With a valid opacity too: nothing of the call is applied. + let p = json!({"id": inst.0, "path": [], key: paint, "opacity": 50}); + match set(s, &p) { + Ok(_) => return Err(format!("{p} was taken")), + Err(e) if !e.to_string().contains(&format!("{key} cannot be set on {kind}")) => return Err(format!("{p}: {e}")), + Err(_) => {} + } + } + } + if (overrides(s, inst), undo_len(s)) != (0, undo) { + return Err("a refused call changed the instance or added an undo step".into()); + } + // Null (taking a change back), opacity and blend are taken. + let clear = json!({"id": inst.0, "path": [], "fill": null, "stroke": null}); + for p in [clear, json!({"id": inst.0, "path": [], "opacity": 50, "blend": "multiply"})] { + set(s, &p).map_err(|e| format!("{p}: {e}"))?; + } + if overrides(s, inst) != 1 { + return Err("opacity and blend were not stored".into()); + } + // The renderer draws no fill or stroke of the object's own: a stored one changes nothing. + let before = page(s); + paint_root_red(s, inst); + if page(s) != before { + return Err("a stored fill and stroke changed the canvas".into()); + } + Ok(()) +} + +fn repeat(s: &mut Session, cmd: &str) { + square(s, 10.0); + select_top(s, 1); + s.execute(cmd, &json!({})).unwrap(); +} + +#[test] +fn a_symbol_instance_in_the_art_takes_a_fill_that_stains_it_and_no_stroke() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on a symbol instance"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo)); + // A stroke stored past the command draws nothing. + let before = page(&s); + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a stroke on a symbol instance draws nothing"); + // The fill stains the nested instance's art. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED, "the fill stains the art"); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_gradient_fill() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let gradient = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#00ff00"}]}); + let p = json!({"id": inst.0, "path": [], "fill": {"gradient": gradient}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // A gradient fill stored past the command draws nothing. + let before = page(&s); + let paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a gradient fill on a symbol instance draws nothing"); + // A solid fill and none are taken. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": {"none": true}})).unwrap(); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_pattern_fill() { + let mut s = session(); + let tile = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 60, "width": 10, "height": 10})).unwrap()); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let p = json!({"id": inst.0, "path": [], "fill": {"swatch": "Dots"}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // The pattern draws on a path. + let probe = square(&mut s, 110.0); + let before = page(&s); + s.execute("paint.setFill", &json!({"ids": [probe.0], "swatch": "Dots"})).unwrap(); + assert!(page(&s) != before, "the pattern draws on a path"); + // A pattern fill stored on the nested instance past the command draws nothing. + let before = page(&s); + let paint = s.doc().unwrap().doc.swatch("Dots").unwrap().paint.clone(); + assert!(matches!(paint, Paint::Pattern { .. }), "{paint:?}"); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a pattern fill on a symbol instance draws nothing"); +} + +#[test] +fn null_takes_back_an_old_change_an_object_does_not_draw() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + let inst = art_of_top(&mut s); + // A change stored before the command refused one, as an older document may hold. + paint_root_red(&mut s, inst); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": null})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_none()); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": null})).unwrap(); + assert_eq!(overrides(&s, inst), 0); +} + +#[test] +fn paths_type_groups_and_shaped_image_paint_take_a_fill_and_stroke() { + let set = |s: &mut Session, inst: NodeId, path: Value| { + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": path, "fill": "#ff0000", "stroke": "#ff0000"})).unwrap() + }; + // A group's own fill paints over its members. + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + assert_eq!(set(&mut s, inst, json!([]))["overrides"], 1); + assert_eq!(pixel(&s, 30, 30), RED, "the group's own fill"); + // A compound path, type and a clipping path. + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + assert_eq!(pixel(&s, 30, 30), RED, "the compound path"); + let mut s = session(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + s.execute("object.clippingMask.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { .. })); + set(&mut s, inst, json!([0])); + set(&mut s, inst, json!([])); + // An image whose fill has Convert to Shape: the fill paints 9 pt past its frame. + let mut s = session(); + place_image(&mut s); + s.execute("appearance.addFill", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#00ff00", "item": 0})).unwrap(); + s.execute("effect.apply", &json!({"effect": "convertToShape.rectangle", "item": 0})).unwrap(); + assert_eq!(pixel(&s, 44, 35), [0, 255, 0, 255]); + let inst = art_of_top(&mut s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on an image without a Convert to Shape or Outline Object effect on it"), "{e}"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 44, 35), RED, "the shaped fill takes the instance's color"); +} + #[test] fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { let mut s = session(); @@ -176,6 +496,666 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() { assert_eq!(overrides(&s, other), 1, "the instance keeps its changes"); } +/// Press the Direct Selection tool at (`x`, `y`), drag to `to` and release, with `m` held: what the +/// drag asked of the UI. +fn direct(s: &mut Session, (x, y): (f64, f64), to: (f64, f64), m: Mods) -> Vec { + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(m), v).unwrap(); + let mut out = vec![]; + if to != (x, y) { + out = s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap(); + out +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The fills of the symbol's own art. +fn symbol_fills(s: &Session) -> Vec { + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + art.children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect() +} + +#[test] +fn direct_selection_paints_one_object_of_one_dynamic_instance() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [inst]); + assert_eq!(sel.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] })); + let undo = undo_steps(&s); + // The Swatches panel, the Color panel and the Control bar set the fill with paint.setFill. + assert_eq!(s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap()["objects"], 1); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(pixel(&s, 80, 30), RED, "the picked object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"], "the symbol's art is unchanged"); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#ff0000".into()), "new art takes the fill"); + // The same paint again records nothing. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(undo_steps(&s), undo + 1); + // Stroke goes to the same object. + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(s.paint.stroke.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the stroke"); + assert!(s.paint.stroke_width > 0.0, "a painted stroke has a weight"); + let o = node(&s, inst).extras().symbol_overrides[0].clone(); + assert_eq!(o.path, vec![1]); + assert!(o.fill.is_some() && o.stroke.is_some()); + // The Eyedropper's click on another object gives the selected object its paint. + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap()["ids"], json!([inst.0])); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255], "the eyedropper's paint"); + assert_eq!(symbol_fills(&s), ["#0000ff", "#0000ff"]); + for _ in 0..7 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(overrides(&s, inst), 0, "undo takes each change back"); + assert_eq!(pixel(&s, 80, 30), BLUE); +} + +#[test] +fn a_drag_of_a_picked_object_moves_nothing_and_says_why() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let (before, undo) = (node(&s, inst).kind.clone(), undo_steps(&s)); + let ui = direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("symbol instance"))), "{ui:?}"); + assert_eq!(node(&s, inst).kind, before, "the instance stays put"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert!(s.execute("object.nudge", &json!({"dx": 1})).is_err(), "the arrow keys too"); + assert!(s.execute("object.rotate", &json!({"angle": 10})).is_err()); + // The Selection tool picks the instance whole again, and it moves. + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before); +} + +/// The Stroke panel and the Control bar show the stroke of the object picked in a dynamic instance. +#[test] +fn the_stroke_panel_shows_a_picked_objects_stroke() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2})).unwrap(); + s.execute("stroke.set", &json!({"ids": [b.0], "weight": 7})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(7.0), "the picked object's weight"); + assert!(!s.doc().unwrap().stroke_mixed().weight, "the instance's art has two weights, the object one"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(2.0)); +} + +#[test] +fn delete_and_cut_of_a_picked_object_remove_nothing() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["edit.cut", "edit.clear"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{cmd}: {e}"); + assert!(s.doc().unwrap().doc.node(inst).is_some(), "{cmd} keeps the instance"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); + // Named by id, the instance is removed. + s.execute("edit.clear", &json!({"ids": [inst.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); +} + +#[test] +fn commands_on_the_selection_leave_a_picked_objects_instance_alone() { + // A dynamic instance with an opacity mask, a plain square and the Desaturate plug-in, with an + // object of the instance picked. + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let desaturate = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); + let plugin = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(desaturate)})).unwrap(); + let mask = square(&mut s, 20.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [inst.0, mask.0]})).unwrap(); + assert!(node(&s, inst).extras().mask.is_some()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst, other, plugin["id"].clone()) + }; + let (mut s, inst, other, plugin) = setup(); + let doc = s.doc().unwrap().doc.clone(); + // Each command runs on its own copy of the setup, compared with that copy as it was, so every + // command that fails is reported. + let mut failures = Vec::new(); + for (cmd, p) in [ + ("object.puppetWarp", json!({"pins": [[80, 30]], "moved": [[90, 40]], "rest": true})), + ("recolor.apply", json!({"map": {"#0000ff": "#00ff00"}})), + ("plugin.run", json!({"id": plugin, "params": {"amount": 100}})), + ("object.group", json!({})), + ("object.ungroup", json!({})), + ("object.hide", json!({})), + ("object.lock", json!({})), + ("object.arrange.bringToFront", json!({})), + ("object.expand", json!({})), + ("object.rotate", json!({"angle": 45})), + ("symbol.instance.reset", json!({})), + ("symbol.instance.setAppearance", json!({"path": [1], "opacity": 50})), + ("symbol.adjust", json!({"tool": "shift", "points": [[80, 30], [95, 30]], "radius": 200})), + ("symbol.edit", json!({})), + ("select.globalEdit", json!({})), + ("object.blend.release", json!({})), + ("object.repeat.release", json!({})), + ("object.intertwine.release", json!({})), + ("object.objectsOnPath.expand", json!({})), + ("transparency.set", json!({"opacity": 50})), + ("transparency.set", json!({"blend": "multiply"})), + ("transparency.releaseOpacityMask", json!({})), + ("transparency.setOpacityMask", json!({"invert": true, "clip": false})), + ("transparency.disableOpacityMask", json!({})), + ("transparency.unlinkOpacityMask", json!({})), + ("transparency.editOpacityMask", json!({})), + ("transparency.viewOpacityMask", json!({"on": true})), + ("paint.editGradient", json!({"angle": 30})), + ("paint.setGradientGeom", json!({"start": [62, 30], "end": [98, 30]})), + ("paint.swap", json!({})), + ("stroke.set", json!({"weight": 3})), + ("path.moveAnchors", json!({"dx": 5, "dy": 5})), + ( + "file.place", + json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(b""), "replace": true}), + ), + ("object.intertwine.arrange", json!({"at": [80, 30]})), + ("transparency.editOpacityMask", json!({"id": null})), + ("transparency.viewOpacityMask", json!({"on": true, "id": null})), + ] { + let (mut t, ..) = setup(); + let (doc, undo) = (t.doc().unwrap().doc.clone(), undo_steps(&t)); + match t.execute(cmd, &p) { + Ok(r) => failures.push(format!("{cmd}: ran ({r})")), + Err(e) if !e.to_string().contains("symbol instance") => failures.push(format!("{cmd}: {e}")), + Err(_) => {} + } + if t.doc().unwrap().selection.symbol_part.is_none() { + failures.push(format!("{cmd}: the object is no longer picked")); + } + if t.doc().unwrap().doc != doc { + failures.push(format!("{cmd}: the document changed")); + } + if undo_steps(&t) != undo { + failures.push(format!("{cmd}: an undo step was added")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + // Copy copies the instance whole. + s.execute("edit.copy", &json!({})).unwrap(); + assert!(matches!(&s.clipboard.nodes[..], [n] if matches!(n.kind, NodeKind::SymbolInstance { .. }))); + // The picked object's paint still changes. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_some()); + // A command given ids acts on them. + s.execute("object.rotate", &json!({"ids": [other.0], "angle": 45})).unwrap(); + assert_ne!(node(&s, other).kind, doc.node(other).unwrap().kind, "the named object turns"); + s.execute("transparency.setOpacityMask", &json!({"id": inst.0, "invert": true})).unwrap(); + assert!(node(&s, inst).extras().mask.as_ref().is_some_and(|m| m.invert), "the named instance's mask changes"); +} + +#[test] +fn the_commands_without_a_check_for_a_picked_object_use_its_instance() { + let setup = || { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + (s, inst) + }; + // Export Selection: the whole instance, x 10..100, and not the object at 60..100. + let (mut s, _) = setup(); + let r = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap(); + assert_eq!(r["bounds"], json!([10.0, 10.0, 90.0, 40.0])); + // Fit to Selection: the whole instance too. + let (mut s, _) = setup(); + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((r.x0, r.x1), (10.0, 100.0)); + // Liquify: the instance is its target, skipped and left as it is. + let (mut s, inst) = setup(); + let doc = s.doc().unwrap().doc.clone(); + let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[80, 30]], "diameter": 150, "intensity": 1})).unwrap(); + assert_eq!((&r["ids"], &r["skipped"]), (&json!([]), &json!([inst.0]))); + assert!(s.doc().unwrap().doc == doc); + // Isolate: the instance, with the selection cleared. + let (mut s, inst) = setup(); + s.execute("object.isolate", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(inst)); + assert!(s.doc().unwrap().selection.is_empty()); + // Add to Library: a copy of the whole instance, 90 pt wide, and not the object's paint. + let (mut s, _) = setup(); + let lib = s.execute("library.add", &json!({"kind": "graphic"})).unwrap()["library"].clone(); + let got = s.execute("library.get", &json!({"library": lib})).unwrap(); + assert_eq!(got["graphics"][0]["width"], 90.0); + assert_ne!(s.execute("library.add", &json!({"kind": "fillColor"})).unwrap()["name"], "#ff0000"); + // Save Selection: the instance, which Recall selects whole. + let (mut s, inst) = setup(); + s.execute("select.save", &json!({"name": "Saved"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.saved_selections[0].objects, vec![inst]); + s.execute("select.recall", &json!({"name": "Saved"})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((&sel.objects, &sel.symbol_part), (&vec![inst], &None)); +} + +#[test] +fn a_blend_around_a_picked_objects_instance_is_left_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = square(&mut s, 150.0); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [inst.0, other.0], "steps": 1})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let (doc, undo) = (s.doc().unwrap().doc.clone(), undo_steps(&s)); + let e = s.execute("object.blend.release", &json!({})).unwrap_err().to_string(); + assert!(e.contains("symbol instance"), "{e}"); + assert!(s.doc().unwrap().doc == doc, "the blend stays"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + // With the blend selected, it is released. + s.execute("select.set", &json!({"ids": [blend.0]})).unwrap(); + s.execute("object.blend.release", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(blend).is_none()); +} + +/// Dynamic symbol "Photo" of a gray 2 × 1 image and a blue square, made in place: its instance and +/// the image's path in the art. +fn image_symbol(s: &mut Session) -> (NodeId, Value) { + let mut png = Vec::new(); + let pixels = image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()); + pixels.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, _| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf: vectorcraft_geom::Affine::translate((10.0, 10.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + Ok(id) + }) + .unwrap(); + let sq = square(s, 60.0); + let inst = id(&s.execute("symbol.new", &json!({"name": "Photo", "ids": [im.0, sq.0], "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + let i = art.children().unwrap().iter().position(|c| matches!(c.kind, NodeKind::Image(_))).unwrap(); + (inst, json!([i])) +} + +#[test] +fn only_paths_compound_paths_and_type_of_the_art_are_picked() { + let mut s = session(); + // A compound path, type, a blend and a static symbol's instance beside a path, in a group. + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + let text = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + let (c, d) = (square(&mut s, 60.0), square(&mut s, 150.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [c.0, d.0], "steps": 1})).unwrap()); + let inner = pair(&mut s, "static"); + let plain = square(&mut s, 100.0); + let group = id(&s.execute("object.group", &json!({"ids": [plain.0]})).unwrap()); + let ids = [compound.0, text.0, blend.0, inner.0, group.0]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Mixed", "ids": ids, "type": "dynamic"})).unwrap()); + let art = s.doc().unwrap().doc.symbols.iter().find(|y| y.name == "Mixed").unwrap().art.clone(); + let at = |kind: &dyn Fn(&NodeKind) -> bool| art.children().unwrap().iter().position(|c| kind(&c.kind)).unwrap(); + let compound = at(&|k| matches!(k, NodeKind::Compound { .. })); + let text = at(&|k| matches!(k, NodeKind::Text(_))); + let blend = at(&|k| matches!(k, NodeKind::Blend { .. })); + let inner = at(&|k| matches!(k, NodeKind::SymbolInstance { .. })); + let group = at(&|k| matches!(k, NodeKind::Group { .. })); + for path in [json!([compound]), json!([text]), json!([group, 0])] { + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + } + assert_eq!(overrides(&s, inst), 3); + let undo = undo_steps(&s); + s.execute("select.none", &json!({})).unwrap(); + for path in [json!([]), json!([group]), json!([blend]), json!([inner])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + assert_eq!((overrides(&s, inst), undo_steps(&s)), (3, undo), "nothing changes"); +} + +/// A Direct Selection press on an image of the art is refused with the status message, and a drag +/// from it does not move what was selected. +#[test] +fn a_press_on_an_image_of_the_art_is_refused_and_moves_nothing() { + let mut s = session(); + let (inst, path) = image_symbol(&mut s); + let other = square(&mut s, 150.0); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let before = node(&s, other).kind.clone(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("directSelection", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 60.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 60.0), v).unwrap(); + assert_eq!(node(&s, other).kind, before, "what was selected stays put"); + assert_eq!(s.doc().unwrap().selection.objects, [other], "and stays selected"); + assert_eq!(overrides(&s, inst), 0); +} + +/// A gradient or pattern for a picked object is refused and changes nothing, new art's paint +/// included. An object that shows a gradient takes a solid color. +#[test] +fn gradients_and_patterns_on_a_picked_object_are_refused() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("swatch.new", &json!({"name": "Ramp", "gradient": g})).unwrap(); + let tile = square(&mut s, 150.0); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + // An object to sample a gradient from. + let far = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [far.0], "gradient": g})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let (fill, stroke) = (s.paint.fill.clone(), s.paint.stroke.clone()); + for (cmd, p) in [ + ("paint.setFill", json!({"gradient": g})), + ("paint.setStroke", json!({"gradient": g})), + ("paint.setFill", json!({"gradient": g, "span": "selection"})), + ("paint.setFill", json!({"swatch": "Ramp"})), + ("paint.setFill", json!({"swatch": "Dots"})), + ("appearance.copyFrom", json!({"source": far.0})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd} {p}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "new art keeps its paint"); + // An object whose art has a gradient takes a solid color; Invert, Complement and selecting one + // of its stops are refused. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": {"gradient": g}})).unwrap(); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + s.fill_active = true; + for index in [json!(1), Value::Null] { + let e = s.execute("gradient.selectStop", &json!({"index": index})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "gradient.selectStop {index}: {e}"); + } + assert_eq!(s.selected_stop(), None); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // The Eyedropper's options can leave the gradient out: the fill stays. + s.execute("appearance.copyFrom", &json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}}}})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + // Options that pick up neither color leave nothing to change, so the click is refused. + let (undo, fill) = (undo_steps(&s), s.paint.fill.clone()); + let neither = json!({"source": far.0, "pickUp": {"appearance": {"fill": {"color": false}, "stroke": {"color": false}}}}); + let e = s.execute("appearance.copyFrom", &neither).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + assert_eq!((undo_steps(&s), &s.paint.fill), (undo, &fill), "nothing changes"); +} + +#[test] +fn select_part_refuses_hidden_and_locked_objects_of_the_art() { + let mut s = session(); + let (a, b, c) = (square(&mut s, 10.0), square(&mut s, 60.0), square(&mut s, 110.0)); + let group = id(&s.execute("object.group", &json!({"ids": [b.0, c.0]})).unwrap()); + let inst = id(&s.execute("symbol.new", &json!({"name": "Three", "ids": [a.0, group.0], "type": "dynamic"})).unwrap()); + // The art's first square is hidden and its group locked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let children = art.children_mut().unwrap(); + std::sync::Arc::make_mut(&mut children[0]).visible = false; + std::sync::Arc::make_mut(&mut children[1]).locked = true; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + for path in [json!([0]), json!([1]), json!([1, 0])] { + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": path})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "{path}: {e}"); + } + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "nothing is picked"); + // With the art's group unlocked, a locked instance's objects are still not picked. + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + std::sync::Arc::make_mut(&mut art.children_mut().unwrap()[1]).locked = false; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("object.lock", &json!({"ids": [inst.0]})).unwrap(); + let e = s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "locked instance: {e}"); + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1, 1]})).unwrap(); +} + +#[test] +fn choosing_the_selection_tool_again_picks_the_instance_whole() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + let revision = s.doc().unwrap().revision; + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(s.doc().unwrap().revision, revision, "panels cached by revision read the instance"); + s.execute("object.nudge", &json!({"dx": 1})).unwrap(); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); +} + +#[test] +fn the_eyedropper_sample_taken_for_a_picked_object_is_kept() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let green = square(&mut s, 150.0); + s.execute("paint.setFill", &json!({"ids": [green.0], "color": "#00ff00"})).unwrap(); + let target = square(&mut s, 150.0); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": green.0})).unwrap(); + assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "new art takes the sample's fill"); + // A reverse Eyedropper click with nothing selected applies that sample. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("appearance.copyFrom", &json!({"source": target.0, "reverse": true})).unwrap()["ids"], json!([target.0])); + assert_eq!(node(&s, target).appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); +} + +#[test] +fn invert_and_complement_recolor_a_picked_object_for_its_instance_only() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + let r = s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(r["changed"], 1, "{r}"); + assert_eq!(undo_steps(&s), undo + 1, "one undo step"); + assert_eq!(symbol_part_fill(&s, inst), "#ffff00", "the picked object's own blue, inverted"); + assert_eq!(overrides(&s, inst), 1, "only the picked object changes"); + assert_eq!(overrides(&s, other), 0, "the other instance keeps the symbol's look"); + assert!(*s.doc().unwrap().doc.symbols[0].art == *art, "the symbol's art stays"); + assert_eq!(pixel(&s, 80, 30), [255, 255, 0, 255], "the changed object"); + assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look"); + s.execute("paint.complement", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#0000ff", "the complement of yellow"); + assert_eq!(undo_steps(&s), undo + 2, "one undo step"); +} + +/// One change holds one paint, so Invert and Complement are refused for type whose characters +/// differ in color, and each character's color stays. A fill gives all of them one color. +#[test] +fn invert_and_complement_are_refused_for_picked_type_whose_characters_differ() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": "#ff0000"})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + let mut art = (*s.doc().unwrap().doc.symbols[0].art).clone(); + let NodeKind::Text(tx) = &mut art.kind else { panic!("type: {art:?}") }; + let mut second = tx.runs[0].clone(); + second.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + tx.runs.push(second); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols[0].art = std::sync::Arc::new(art); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + let undo = undo_steps(&s); + for cmd in ["paint.invert", "paint.complement"] { + let e = s.execute(cmd, &json!({"stroke": false})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{cmd}: {e}"); + } + assert_eq!((overrides(&s, inst), undo_steps(&s)), (0, undo), "nothing changes"); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("paint.invert", &json!({"stroke": false})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#ff00ff", "one color inverts"); +} + +/// The color of the fill instance `inst`'s first change sets. +fn symbol_part_fill(s: &Session, inst: NodeId) -> String { + let o = node(s, inst).extras().symbol_overrides[0].clone(); + o.fill.as_ref().and_then(|p| p.color()).map(|c| c.to_hex()).unwrap_or_else(|| panic!("a solid fill: {o:?}")) +} + +#[test] +fn a_stroke_on_a_picked_type_object_without_a_weight_draws() { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()); + s.execute("paint.setStroke", &json!({"ids": [t.0], "none": true})).unwrap(); + let inst = id(&s.execute("symbol.new", &json!({"name": "Word", "ids": [t.0], "type": "dynamic"})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": []})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let part = s.doc().unwrap().selected_symbol_part().unwrap(); + let NodeKind::Text(tx) = &part.kind else { panic!("type: {part:?}") }; + assert!(tx.runs.iter().all(|r| r.style.stroke_width == 1.0 && !r.style.stroke.is_none()), "{:?}", tx.runs); +} + +/// The colors the Fill and Stroke proxies show, as `paint.proxies` replies them. +fn proxy_colors(s: &mut Session) -> (Option, Option) { + let r = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!((r["fillMixed"].clone(), r["strokeMixed"].clone()), (json!(false), json!(false)), "{r}"); + let (fill, stroke) = s.proxy_paints(); + assert_eq!((r["fill"].clone(), r["stroke"].clone()), (json!(fill), json!(stroke))); + (fill.color().map(|c| c.to_hex()), stroke.color().map(|c| c.to_hex())) +} + +#[test] +fn the_fill_and_stroke_proxies_show_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None), "the symbol's own paint"); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#ff0000".into()), Some("#00ff00".into())), "the object's own paint"); + // The other object of the same instance shows the symbol's paint. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [0]})).unwrap(); + assert_eq!(proxy_colors(&mut s), (Some("#0000ff".into()), None)); +} + +#[test] +fn selecting_a_guide_too_lets_go_of_the_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let g = s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap()["index"].as_u64().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [g], "toggle": true})).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.guides.len(), sel.symbol_part), (vec![inst], 1, None)); + // Paint now goes to the instance, not to the object. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(overrides(&s, inst), 0); + // Pruning a selection with a guide in it lets go of the object as well. + let mut sel = s.doc().unwrap().selection.clone(); + sel.symbol_part = Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![1] }); + sel.prune(&s.doc().unwrap().doc); + assert_eq!(sel.symbol_part, None); +} + +/// A dynamic symbol with 9-slice guides at 25%: a 100 × 100 frame at (50, 50), a 10 × 10 square +/// in its top-left corner and one in its center; its instance resized to 300 × 100. +fn sliced_panel() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let rect = + |s: &mut Session, x: f64, y: f64, w: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()).0; + let ids = [rect(&mut s, 50.0, 50.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0)]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Panel", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + (s, inst) +} + +#[test] +fn direct_selection_picks_and_outlines_the_object_where_a_9_slice_instance_draws_it() { + let (mut s, inst) = sliced_panel(); + let part_box = |s: &Session, path: &[u32]| { + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), path).unwrap(); + vectorcraft_geom::kurbo::Shape::bounding_box(&o) + }; + let close = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6); + // The center cell (75..125 of the art) is drawn at 75..325, so the center square at 175..225; + // placed by the instance's transform alone it would be at 185..215. + assert!(close(part_box(&s, &[2]), Rect::new(175.0, 95.0, 225.0, 105.0)), "{:?}", part_box(&s, &[2])); + assert!(close(part_box(&s, &[1]), Rect::new(50.0, 50.0, 60.0, 60.0)), "the corner keeps its size: {:?}", part_box(&s, &[1])); + direct(&mut s, (177.0, 100.0), (177.0, 100.0), Mods::default()); + assert_eq!(s.doc().unwrap().selection.symbol_part, Some(vectorcraft_doc::SymbolPart { instance: inst, path: vec![2] })); + direct(&mut s, (165.0, 100.0), (165.0, 100.0), Mods::default()); + let picked = s.doc().unwrap().selection.symbol_part.clone().map(|p| p.path); + assert_ne!(picked, Some(vec![2]), "where the square is not drawn"); +} + +/// An object that is drawn once in each 9-slice cell it reaches, rather than cut at the guides +/// (here area type), is outlined where its copies are drawn. +#[test] +fn the_outline_of_an_object_drawn_in_each_9_slice_cell_matches_what_is_drawn() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let frame = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()).0; + let area = json!({"width": 80, "height": 10}); + let text = id(&s.execute("text.create", &json!({"x": 60, "y": 95, "area": area, "size": 6, "text": "Hi"})).unwrap()).0; + let ids = [frame, text]; + let inst = id(&s.execute("symbol.new", &json!({"name": "Bar", "ids": ids, "type": "dynamic", "nineSlice": true})).unwrap()); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 300, "height": 100, "reference": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + let o = vectorcraft_doc::hit::symbol_part_outline(d, d.node(inst).unwrap(), &[1]).unwrap(); + let b = vectorcraft_geom::kurbo::Shape::bounding_box(&o); + // The left copy is drawn at 60..75, the center one (placed whole it reaches 0..400) at 75..325 + // and the right one at 325..340. + let want = Rect::new(60.0, 95.0, 340.0, 105.0); + assert!([(b.x0, want.x0), (b.y0, want.y0), (b.x1, want.x1), (b.y1, want.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}"); +} + +#[test] +fn direct_selection_picks_a_static_instance_whole_as_before() { + let mut s = session(); + let inst = pair(&mut s, "static"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.objects, sel.symbol_part), (vec![inst], None)); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).is_err(), "a static symbol"); + // A dynamic one: Alt picks the instance whole, and a path naming no object is refused. + s.execute("symbol.options", &json!({"name": "Pair", "type": "dynamic"})).unwrap(); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..Mods::default() }); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert!(s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [2]})).is_err()); + assert!(s.execute("symbol.instance.selectPart", &json!({"path": [1]})).is_err(), "no id"); + // Making the symbol static again lets go of the picked object. + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("symbol.options", &json!({"name": "Pair", "type": "static"})).unwrap(); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); +} + #[test] fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before() { let mut s = session(); @@ -191,3 +1171,201 @@ fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before assert_eq!(back.node(inst).unwrap().extras().symbol_overrides, doc.node(inst).unwrap().extras().symbol_overrides); assert_eq!(back.symbols, doc.symbols); } + +#[test] +fn a_swatch_edit_recolors_a_picked_objects_own_paints() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert_eq!(pixel(&s, 80, 30), RED); + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + let fill = s.doc().unwrap().selected_symbol_part().unwrap().appearance.fill_paint(); + assert_eq!(fill.color().map(|c| c.to_hex()), Some("#00ff00".into()), "{fill:?}"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +#[test] +fn a_swatch_or_color_only_a_picked_objects_paint_uses_counts_as_used() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap(); + let inst = pair(&mut s, "dynamic"); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone(); + assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand"))); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Brand"})).unwrap(); + assert!(!unused(&mut s).as_array().unwrap().contains(&json!("Brand")), "only the object's own paint uses it"); + let colors = s.execute("recolor.colors", &json!({"ids": [inst.0]})).unwrap(); + assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000" && c["swatch"] == "Brand"), "{colors}"); + // A swatch edit maps the same paint once. + s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); +} + +/// The Eyedropper takes a picked object's paints only from a path, compound path or type. +#[test] +fn the_eyedropper_refuses_a_source_that_is_not_painted_art() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap()); + let (a, b) = (square(&mut s, 150.0), square(&mut s, 150.0)); + let group = id(&s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let (fill, stroke, own, undo) = (s.paint.fill.clone(), s.paint.stroke.clone(), node(&s, inst).extras().symbol_overrides.clone(), undo_steps(&s)); + s.eyedropper_sample = None; + for source in [inst, other, group] { + let e = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + } + // A click with the tool on an instance, the picked one included. + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("eyedropper", v).unwrap(); + let e = s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 30.0), v).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 30.0), v).unwrap(); + assert_eq!(node(&s, inst).extras().symbol_overrides, own, "the object keeps its paints"); + assert_eq!(undo_steps(&s), undo, "no undo step"); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &stroke), "the defaults for new art stay"); + assert!(s.eyedropper_sample.is_none(), "nothing was sampled"); + assert_eq!(pixel(&s, 80, 30), RED); +} + +/// A path of a compound path gives the paint of the compound path, which holds it. +#[test] +fn the_eyedropper_samples_a_compound_path_through_its_member() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let (a, b) = (square(&mut s, 120.0), square(&mut s, 170.0)); + let compound = id(&s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [compound.0], "color": "#00ff00"})).unwrap(); + let member = node(&s, compound).children().unwrap()[0].id; + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": member.0})).unwrap(); + assert_eq!(symbol_part_fill(&s, inst), "#00ff00"); + assert_eq!(pixel(&s, 80, 30), [0, 255, 0, 255]); +} + +/// A Cmd-drag lends the Selection tool, or with Alt Group Selection, which act on the whole instance. +#[test] +fn a_lent_selection_tool_moves_the_instance_of_a_picked_object() { + let cmd = Mods { cmd: true, ..Mods::default() }; + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + let before = node(&s, inst).kind.clone(); + direct(&mut s, (80.0, 30.0), (100.0, 40.0), cmd); + assert!(s.doc().unwrap().selection.symbol_part.is_none()); + assert_ne!(node(&s, inst).kind, before, "the instance moves"); + + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods { alt: true, ..cmd }); + assert!(s.doc().unwrap().selection.symbol_part.is_none(), "Group Selection selects the instance whole"); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); + // A plain Direct Selection click picks the object again once the tool is back. + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); +} + +/// The commands that change nothing in the document read the instance of a picked object, so the +/// Attributes and CSS Properties panels and the others keep showing it. +#[test] +fn read_only_commands_read_the_instance_of_a_picked_object() { + let mut failures = Vec::new(); + for spec in crate::cmd::command_specs().iter().filter(|c| !c.journal) { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + if let Err(e) = s.execute(spec.id, &json!({})) + && e.to_string().contains("symbol instance") + { + failures.push(format!("{}: {e}", spec.id)); + } + if s.doc().is_ok_and(|st| st.doc != doc) { + failures.push(format!("{}: the document changed", spec.id)); + } + // A command run after one of these is refused as before. + if s.doc().is_ok_and(|st| st.selection.symbol_part.is_some()) && s.execute("object.hide", &json!({})).is_ok() { + failures.push(format!("{}: the refusal stays off afterward", spec.id)); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.execute("css.selection", &json!({})).unwrap(); + s.execute("attributes.info", &json!({})).unwrap(); +} + +/// Export CSS writes a file and changes nothing in the document, so it reads the instance of a +/// picked object as it reads an instance selected whole, and the next edit is refused as before. +#[test] +fn css_export_reads_the_instance_of_a_picked_object() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let whole = s.execute("css.export", &json!({})).unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let picked = s.execute("css.export", &json!({})).unwrap(); + assert_eq!(picked["rules"], 1); + assert_eq!(picked["data"], whole["data"]); + assert!(s.doc().unwrap().doc == doc, "the document changed"); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + assert!(s.execute("object.hide", &json!({})).is_err()); +} + +/// A drag that moved nothing leaves Transform Again repeating the last real transformation. +#[test] +fn a_refused_drag_of_a_picked_object_is_not_kept_for_transform_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + direct(&mut s, (80.0, 30.0), (80.0, 30.0), Mods::default()); + assert!(s.doc().unwrap().selection.symbol_part.is_some()); + direct(&mut s, (80.0, 30.0), (120.0, 60.0), Mods::default()); + assert!(s.doc().unwrap().last_transform.is_none(), "{:?}", s.doc().unwrap().last_transform); + assert_eq!(s.doc().unwrap().selection.objects, [inst]); +} + +/// A bad `target` or `aligned` on a paint command is refused with an object picked too. +#[test] +fn bad_paint_parameters_leave_a_picked_object_alone() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + let undo = undo_steps(&s); + for (cmd, p) in [ + ("paint.setFill", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.setStroke", json!({"color": "#ff0000", "target": "bogus"})), + ("paint.invert", json!({"target": "bogus"})), + ("paint.complement", json!({"target": "bogus"})), + ("paint.setFill", json!({"color": "#ff0000", "aligned": "yes"})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`target` must") || e.contains("aligned"), "{cmd} {p}: {e}"); + assert_eq!(overrides(&s, inst), 0, "{cmd} {p}"); + } + assert_eq!(undo_steps(&s), undo, "no undo step"); +} + +/// An object picked while Replace read its file: the instance selected when Place started is +/// selected again first, so a replay of the journal replaces it. +#[test] +fn a_replace_that_finishes_after_an_object_was_picked_selects_the_instance_again() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + s.execute("select.set", &json!({"ids": [inst.0]})).unwrap(); + let svg = ""; + let p = json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes()), "replace": true}); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("symbol.instance.selectPart", &json!({"id": inst.0, "path": [1]})).unwrap(); + s.place_read(read).unwrap(); + assert!(s.doc().unwrap().doc.node(inst).is_none()); + let last: Vec<_> = s.journal.iter().rev().take(2).rev().map(|(id, _)| id.clone()).collect(); + assert_eq!(last, ["select.set", "file.place"]); +} diff --git a/crates/engine/src/tests/eyedropper.rs b/crates/engine/src/tests/eyedropper.rs index 786c492da..f8363fe93 100644 --- a/crates/engine/src/tests/eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -333,8 +333,8 @@ fn an_rgb_image_sampled_in_a_cmyk_document_separates_through_the_color_settings( let got = node(&s, dst).appearance.fill_paint().color().unwrap(); assert_eq!(got, managed_cmyk(Color::rgb8(83, 170, 223))); let Color::Cmyk { c, m, y, k } = got else { panic!("{got:?}") }; - // Generic CMYK: about C59 M12 Y3 K0, with no black, where the formula gave K13. - assert!((0.5..0.75).contains(&c) && (0.05..0.2).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}"); + // Generic CMYK: about C62 M19 Y0 K0, with no black, where the formula gave K13. + assert!((0.5..0.75).contains(&c) && (0.1..0.25).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}"); // A light blue shows close to the color it was picked from; the formula's C12 M7 showed duller. click(&mut s, 450.0, 50.0, Mods::default()); let got = node(&s, dst).appearance.fill_paint().color().unwrap(); diff --git a/crates/engine/src/tests/fontlist.rs b/crates/engine/src/tests/fontlist.rs index d0804b7d0..6202a8ce7 100644 --- a/crates/engine/src/tests/fontlist.rs +++ b/crates/engine/src/tests/fontlist.rs @@ -14,7 +14,11 @@ fn names(v: &Value) -> Vec { fn the_font_list_names_every_available_family_with_its_styles() { let mut s = Session::new(); let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]); - assert_eq!(fams, *FontDb::global().menu_family_list()); + // Other tests running at the same time add and remove composite fonts, which the lists show + // first; the installed families are compared. + let db = FontDb::global(); + let installed_only = |list: &[String]| list.iter().filter(|f| db.has_installed_family(f)).cloned().collect::>(); + assert_eq!(installed_only(&fams), installed_only(&db.menu_family_list())); for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { assert!(fams.iter().any(|x| x == f), "{f}"); } diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs index 0c78b1109..3c2c9b9eb 100644 --- a/crates/engine/src/tests/history_document.rs +++ b/crates/engine/src/tests/history_document.rs @@ -1,5 +1,6 @@ //! History panel › New Document from State (`edit.newDocumentFromState`). +use std::path::Path; use std::sync::Arc; use serde_json::{Value, json}; @@ -262,7 +263,72 @@ fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() { s.execute(CMD, &json!({})).unwrap(); assert_eq!(s.active_index(), Some(1), "the new document"); let c = check(&mut s); - assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}"); +} + +/// A linked file missing from the moved source keeps the place its relative path names from the +/// source's folder: the new document, saved beside the source, writes the same relative path. +#[test] +fn a_missing_file_keeps_its_relative_path_in_the_new_document() { + let a = Folder::new("history-document-missing-a"); + let b = Folder::new("history-document-missing-b"); + let away = Folder::new("history-document-missing-away"); + write(&a.file("art/photo.png"), &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + let r = s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + assert_eq!(r["missingLinks"][0]["name"], "photo.png", "{r}"); + s.execute(CMD, &json!({})).unwrap(); + let copy = b.file("copy.vectorcraft"); + s.execute("document.save", &json!({"path": copy})).unwrap(); + let saved: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap(); + let link = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"]; + assert_eq!(link["relative"].as_str(), Some("art/photo.png"), "{link}"); +} + +/// A linked file that comes back beside the document the source was read from, after the source +/// was saved in another folder, is found from the new document. +#[test] +fn a_file_back_beside_the_source_it_was_read_from_is_found_from_the_new_document() { + let a = Folder::new("history-document-back-a"); + let b = Folder::new("history-document-back-b"); + let away = Folder::new("history-document-back-away"); + write(&a.file("art/photo.png"), &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + s.execute("document.save", &json!({"path": b.file("saved/poster.vectorcraft")})).unwrap(); + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let source = s.active_index().unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let c = s.execute("links.check", &json!({})).unwrap(); + let moved = b.file("art/photo.png"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}"); + // A file beside the folder the source was last saved in is found too. + std::fs::create_dir_all(b.0.join("saved/art")).unwrap(); + std::fs::rename(&moved, b.file("saved/art/photo.png")).unwrap(); + s.execute("document.activate", &json!({"index": source})).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let c = s.execute("links.check", &json!({})).unwrap(); + let saved = b.file("saved/art/photo.png"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&saved))), "{c}"); + // Missing again when the new document is saved, the file keeps the place it was found. + std::fs::rename(&saved, away.file("photo.png")).unwrap(); + std::fs::create_dir_all(b.0.join("new")).unwrap(); + let copy = b.file("new/copy.vectorcraft"); + s.execute("document.save", &json!({"path": copy})).unwrap(); + let file: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap(); + let link = &file["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"]; + assert_eq!(link["relative"].as_str(), Some("../saved/art/photo.png"), "{link}"); } /// The same holds for a linked image only a symbol shows. diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index f8449c104..8e26577f9 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -135,8 +135,9 @@ fn id_does_what_ids_of_one_does_with_nothing_selected() { /// Every command that takes `id` as `ids` of one ([`cmd::params::id_stands_for_ids`]), with an /// object selected: an `id` that is malformed (a string, negative, a fraction, an object) or names -/// no object is refused under the command's id, as the same value in `ids` is, and the document -/// and the selection stay as they were. It never stands for the selection instead. (A string `id` +/// no object is refused under the command's id, as the same value in `ids` is (whose error for a +/// malformed entry also names the key and the entry), and the document and the selection stay as +/// they were. It never stands for the selection instead. (A string `id` /// given to `effect.apply` without `effect` names the effect, which "bad" isn't: refused too.) #[test] fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() { @@ -158,7 +159,10 @@ fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() { let st = s.doc().unwrap(); (r, st.doc == before && st.selection.objects == vec![NodeId(d)]) }; - let (with_id, with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()]))); + let (with_id, mut with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()]))); + // The error for an `ids` entry names the key and the entry; the one for `id` names the value. + let entry = format!("`ids` entry 0 is {bad}, not a non-negative integer object id"); + with_ids.0 = with_ids.0.map_err(|e| e.replace(&entry, &format!("invalid object id {bad}: expected a non-negative integer"))); let effect_named = spec.id == "effect.apply" && bad.is_string(); if with_id.0.is_ok() || !with_id.1 || (with_id != with_ids && !effect_named) { failures.push(format!( @@ -301,3 +305,205 @@ fn ids_of_an_object_and_a_group_that_holds_it_do_what_that_selection_does() { assert!(failures.is_empty(), "{} differ:\n{}", failures.len(), failures.join("\n")); assert!(succeeded >= 150, "only {succeeded} runs succeeded"); } + +/// What `ids` (or `slices`) left out or null gives a command: the selection, or the command's own +/// default. +enum Unset { + /// The selected objects: a result, the same as the list of the selection gives. + Selected, + /// The selected objects, which the command refuses with this error, as it refuses the list of + /// the selection. + SelectionRefused(&'static str), + /// This error, which the list of the selection does not give. + Fails(&'static str), + /// This result, which the list of the selection does not give. + Gives(serde_json::Value), +} + +/// The commands that read their `ids` (or `slices`) list themselves or pass it to a colour pass, +/// with the rectangle apart (`d`): the key, the other params, and what leaving the key out gives. +fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unset)> { + use Unset::*; + vec![ + ("appearance.copyFrom", "ids", json!({ "source": d }), Selected), + ("edit.colors.invert", "ids", json!({}), Selected), + ("edit.colors.toCMYK", "ids", json!({}), Selected), + ("edit.colors.toGrayscale", "ids", json!({}), Selected), + ("edit.colors.toRGB", "ids", json!({}), Selected), + ("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected), + ("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected), + ("paint.invert", "ids", json!({}), Selected), + ("paint.complement", "ids", json!({}), Selected), + ("addon.color.filter", "ids", json!({"effect": "addon.color.invert"}), Selected), + ("addon.color.applyNearestSwatch", "ids", json!({}), Selected), + ("edit.colors.overprintBlack", "ids", json!({}), Selected), + ("edit.findReplace", "ids", json!({"find": "A", "replace": "B"}), Gives(json!({"count": 1}))), + ("layer.highlight", "ids", json!({}), Fails("missing ids")), + ("layer.move", "ids", json!({ "target": d }), Fails("missing ids")), + ("library.add", "ids", json!({"kind": "graphic"}), Selected), + ("library.add", "ids", json!({"kind": "fillColor"}), Selected), + ("links.check", "ids", json!({}), Selected), + ("links.update", "ids", json!({}), Selected), + ("links.relink", "ids", json!({"path": "missing.png"}), Fails("select the images to relink, or pass ids")), + ("links.embed", "ids", json!({}), Fails("select the linked images or placed documents, or pass ids")), + ("links.placementOptions", "ids", json!({}), Fails("select the images, or pass ids")), + ("object.transform", "ids", json!({"matrix": [1, 0, 0, 1, 10, 0]}), Selected), + ("object.pattern.make", "ids", json!({"name": "P"}), Selected), + ("object.repeat.radial", "ids", json!({}), Selected), + ("object.repeat.grid", "ids", json!({}), Selected), + ("object.repeat.mirror", "ids", json!({}), Selected), + ("object.slice.make", "ids", json!({}), Selected), + ("object.slice.move", "slices", json!({"dx": 1, "dy": 1}), Selected), + ("object.slice.delete", "slices", json!({}), Selected), + ("object.slice.select", "slices", json!({}), Gives(json!({"selected": []}))), + ("path.join", "ids", json!({}), SelectionRefused("path is already closed")), + ("path.averageJoin", "ids", json!({"to": "corner"}), SelectionRefused("select two open path ends, or two open paths, to average and join")), + ("symbol.edit", "ids", json!({}), Fails("the document has no symbols")), + ("transparency.makeOpacityMask", "ids", json!({}), Selected), + ] +} + +/// What `cmd` does in a fresh [`doc_with_type`] with `selected` selected (made an object slice +/// first when `slice`): its result or error, the document, the selection and the undo history's +/// length. +fn outcome( + cmd: &str, + p: &serde_json::Value, + selected: &[u64], + slice: bool, +) -> (std::result::Result, vectorcraft_doc::Document, vectorcraft_doc::Selection, usize) { + let (mut s, ..) = doc_with_type(); + if slice { + s.execute("object.slice.make", &json!({ "ids": selected })).unwrap(); + } + s.execute("select.set", &json!({ "ids": selected })).unwrap(); + let r = s.execute(cmd, p).map_err(|e| e.to_string()); + let st = s.doc().unwrap(); + (r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len()) +} + +/// With the rectangle apart (`d`) selected and with nothing selected, a list with an entry that is +/// not a non-negative integer (a string, true, an object) is refused with an error naming the key, +/// the entry and its value, a value that is not a list with an error naming the key, and the +/// document, the selection and the undo history stay as they were. No entry is dropped. A list of +/// the rectangle is not refused. +#[test] +fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() { + let (_, [.., d], _) = doc_with_type(); + let mut failures = vec![]; + for (cmd, key, params, _) in &ids_cases(d) { + let bads = [json!(["abc"]), json!([d, true]), json!([{}]), json!("abc")]; + for (bad, selected) in bads.iter().flat_map(|b| [(b, true), (b, false)]) { + // A fresh document each time, so no mode one case enters carries into the next. + let (mut s, ..) = doc_with_type(); + if selected { + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + } + let st = s.doc().unwrap(); + let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len()); + let mut p = params.clone(); + p[*key] = bad.clone(); + let r = s.execute(cmd, &p); + let want = match bad.as_array() { + Some(a) => { + let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap(); + format!("`{key}` entry {index} is {value}, not a non-negative integer object id") + } + None => format!("{key} must be an array of non-negative integer object ids"), + }; + match &r { + Err(EngineError::BadParams { cmd: c, msg }) if c == cmd && *msg == want => {} + r => failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): {r:.160?}")), + } + let st = s.doc().unwrap(); + if (st.doc.clone(), st.selection.clone(), st.history.undo.len()) != before { + failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): the document, the selection or the undo history changed")); + } + } + let (mut s, ..) = doc_with_type(); + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + let mut p = params.clone(); + p[*key] = json!([d]); + if let Err(EngineError::BadParams { msg, .. }) = s.execute(cmd, &p) + && (msg.starts_with(&format!("`{key}` entry ")) || msg.starts_with(&format!("{key} must be an array"))) + { + failures.push(format!("{cmd} {key}: [{d}] is refused: {msg}")); + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + +/// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and +/// with it null do the same: what the selection's list does for the commands that take the +/// selection, and the command's own default for the others (`object.slice.select` deselects the +/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through +/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes +/// nothing. +#[test] +fn ids_left_out_or_null_take_the_selection_or_the_default() { + let (_, [.., d], _) = doc_with_type(); + let mut failures = vec![]; + for (cmd, key, params, unset) in ids_cases(d) { + // The commands on `slices` act on the selected object once it is an object slice. + let slice = key == "slices"; + let left_out = outcome(cmd, ¶ms, &[d], slice); + let mut null = params.clone(); + null[key] = serde_json::Value::Null; + let with_null = outcome(cmd, &null, &[d], slice); + if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") { + let (mut s, ..) = doc_with_type(); + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + let st = s.doc().unwrap(); + let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids")); + if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) { + failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0)); + } + } else if with_null != left_out { + failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0)); + } + let mut listed = params.clone(); + listed[key] = json!([d]); + let listed = outcome(cmd, &listed, &[d], slice); + let ok = match &unset { + Unset::Selected => left_out.0.is_ok() && listed == left_out, + Unset::SelectionRefused(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed == left_out, + Unset::Fails(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed.0 != left_out.0, + Unset::Gives(want) => left_out.0.as_ref() == Ok(want) && listed.0 != left_out.0, + }; + if !ok { + failures.push(format!("{cmd} {key}: left out: {:.200?}; listed: {:.200?}", left_out.0, listed.0)); + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + +/// With no document open, every command that takes `ids` refuses a list with an entry that is not +/// a non-negative integer, and a value that is not a list, with the same error as with a document +/// open (`effect.list` and `links.findFiles` run without a document). +#[test] +fn an_ids_entry_that_is_not_an_id_is_refused_with_no_document() { + let (mut failures, mut checked) = (vec![], 0); + for spec in command_specs() { + if !cmd::params::takes_key(spec.params, "ids") { + continue; + } + checked += 1; + for bad in [json!(["abc"]), json!([1, true]), json!("abc")] { + let mut s = Session::new(); + let r = s.execute(spec.id, &json!({ "ids": bad })); + let want = match bad.as_array() { + Some(a) => { + let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap(); + format!("`ids` entry {index} is {value}, not a non-negative integer object id") + } + None => "ids must be an array of non-negative integer object ids".to_string(), + }; + match &r { + Err(EngineError::BadParams { cmd: c, msg }) if c == spec.id && *msg == want => {} + r => failures.push(format!("{} ids: {bad}: {r:.160?}", spec.id)), + } + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); + assert!(checked >= 100, "only {checked} commands take ids"); +} diff --git a/crates/engine/src/tests/link_paths.rs b/crates/engine/src/tests/link_paths.rs index 76bd7168a..e39cf6689 100644 --- a/crates/engine/src/tests/link_paths.rs +++ b/crates/engine/src/tests/link_paths.rs @@ -85,6 +85,21 @@ fn linked_files_on_other_computers_are_never_read() { assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys()); } +/// A place a link names from the file the document was read from is followed only when the +/// document's own file follows it too: after a document read from a network share is saved on +/// this computer, its links are not looked for on that share. +#[test] +fn places_named_from_a_share_are_not_followed_after_a_save_elsewhere() { + use crate::cmd::links::follow_from; + let resolve = + |stored: &str, doc: Option<&str>| craft_docpath::resolve(stored, doc.map(std::path::Path::new)).map(|p| p.to_string_lossy().into_owned()); + let (link, on_share) = (r"\\server\share\a\art\photo.png", Some(r"\\server\share\a\poster.vectorcraft")); + assert_eq!(follow_from(link, on_share, on_share, resolve).as_deref(), Some(link)); + assert_eq!(follow_from(link, on_share, Some(r"\\server\share\b\poster.vectorcraft"), resolve).as_deref(), Some(link)); + assert_eq!(follow_from(link, on_share, Some("/local/b/poster.vectorcraft"), resolve), None); + assert_eq!(follow_from(link, on_share, Some(r"C:\b\poster.vectorcraft"), resolve), None); +} + /// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The /// share's root folder has no trailing separator (`\\?\UNC\server\share`). fn on_share(folder: &str) -> Option { diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index cfe43ee8e..f0a0674f0 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -244,6 +244,141 @@ fn the_relative_path_finds_the_file_after_the_folder_moves() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// A document moved to another folder while its linked file is missing keeps the file's relative +/// path when it is saved again, there or in a subfolder, so the file is found once it is back +/// beside the document. +#[test] +fn a_missing_link_keeps_its_relative_path_when_the_moved_document_is_saved() { + let a = Folder::new("missing-save-a"); + let b = Folder::new("missing-save-b"); + let away = Folder::new("missing-save-away"); + let pic = a.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + // Move the document and its links, then take the links away. + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("photo.png"), "{r}"); + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("copies")).unwrap(); + save(&mut s, &b.file("copies/poster.vectorcraft")); + let relative = |path: &str| { + let mut saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + saved_image(&mut saved)["link"]["relative"].as_str().map(str::to_string) + }; + assert_eq!(relative(&b.file("poster.vectorcraft")).as_deref(), Some("art/photo.png")); + assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png")); + // Saved again where the last save put it, the file is still named from the folder it was read from. + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png")); + // The links come back: both saved documents find the file. + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let moved = b.file("art/photo.png"); + for doc in [b.file("poster.vectorcraft"), b.file("copies/poster.vectorcraft")] { + let r = open(&mut s, &doc); + assert_eq!(&r["missingLinks"], &json!([]), "{doc}: {r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&moved), "{doc}"); + } +} + +/// A file found by name in the document's folder, then missing before the next save, keeps the +/// place it was last found: the saved relative path names that place, not the folder its old +/// relative path named. +#[test] +fn a_file_found_elsewhere_then_missing_keeps_the_place_it_was_found() { + let dir = Folder::new("found-then-missing"); + let pic = dir.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc = dir.file("poster.vectorcraft"); + save(&mut s, &doc); + let beside = dir.file("photo.png"); + std::fs::rename(&pic, &beside).unwrap(); + let r = open(&mut s, &doc); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside)); + let away = dir.file("away.png"); + std::fs::rename(&beside, &away).unwrap(); + save(&mut s, &doc); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png")); +} + +/// After a save in another folder, a file Update Links finds by name beside the saved document, +/// then missing before the next save, keeps the place it was found. +#[test] +fn a_file_update_links_finds_after_a_save_elsewhere_keeps_its_place() { + let a = Folder::new("update-after-save-as-a"); + let b = Folder::new("update-after-save-as-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + open(&mut s, &a.file("poster.vectorcraft")); + let doc = b.file("poster.vectorcraft"); + save(&mut s, &doc); + let beside = b.file("photo.png"); + std::fs::rename(&pic, &beside).unwrap(); + let r = s.execute("links.update", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(&r["missing"], &json!([]), "{r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside)); + std::fs::remove_file(&beside).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png")); +} + +/// A file found at its relative path from the folder of the last save, away from its path and from +/// the folder the document was read from, is written relative to where it was found. +#[test] +fn a_file_found_beside_the_last_save_is_written_where_it_was_found() { + let a = Folder::new("found-beside-save-a"); + let b = Folder::new("found-beside-save-b"); + let c = Folder::new("found-beside-save-c"); + write(&a.file("art/photo.png"), &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + save(&mut s, &a.file("poster.vectorcraft")); + open(&mut s, &a.file("poster.vectorcraft")); + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::rename(a.0.join("art"), b.0.join("art")).unwrap(); + save(&mut s, &c.file("poster.vectorcraft")); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + +/// After a moved document with a missing link is saved in another folder, `links.check` finds the +/// file once it is back at the place its relative path names from the folder the document was +/// read from, which is where the saved file looks for it too. +#[test] +fn a_missing_link_comes_back_where_the_document_saved_elsewhere_looks_for_it() { + let a = Folder::new("check-after-save-as-a"); + let b = Folder::new("check-after-save-as-b"); + let c = Folder::new("check-after-save-as-c"); + let away = Folder::new("check-after-save-as-away"); + write(&a.file("art/photo.png"), &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + save(&mut s, &a.file("poster.vectorcraft")); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + save(&mut s, &c.file("poster.vectorcraft")); + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let r = s.execute("links.check", &json!({})).unwrap(); + let found = r["links"][0]["found"].as_str().map(Path::new); + assert_eq!((r["missing"].as_u64(), found), (Some(0), Some(Path::new(&b.file("art/photo.png")))), "{r}"); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + /// Links in symbols, graph designs, patterns and opacity masks are saved with their relative /// paths, as links in the layers are: after the folder moves, each file is found and its link takes /// the new path, which the next save writes relative to the document again. @@ -781,7 +916,7 @@ fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() { assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}"); let key = image(&s, id).key; assert!(s.doc().unwrap().doc.images[&key].is_proxy()); - let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default()); assert!(warnings.is_empty(), "{warnings:?}"); assert!(!full.images[&key].is_proxy()); let dim = @@ -812,7 +947,7 @@ fn export_reads_a_found_svg_link_into_its_own_images_only() { let bytes = png(20, 20, RED); write(&dir.file("a.png"), &bytes); - let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default()); assert_eq!(warnings.len(), 1, "b.png is still missing: {warnings:?}"); assert_eq!(*full.images[&a.key].bytes, bytes, "a.png's pixels"); assert_eq!(full.images[&b.key].bytes, placeholder, "b.png keeps its placeholder"); @@ -845,7 +980,7 @@ fn export_reads_a_changed_svg_link_into_its_own_images_only() { let doc = &s.doc().unwrap().doc; assert!(doc.images[&a.key].is_proxy() && doc.images[&b.key].is_proxy(), "previews"); let preview = doc.images[&a.key].bytes.clone(); - let (full, warnings) = cmd::links::full_linked_images(doc, None); + let (full, warnings) = cmd::links::full_linked_images(doc, Default::default()); assert_eq!(warnings.len(), 1, "a.png is missing: {warnings:?}"); assert_eq!(*full.images[&b.key].bytes, blue, "b.png's new pixels"); assert_eq!(full.images[&a.key].bytes, preview, "a.png keeps its preview"); @@ -1002,7 +1137,7 @@ fn relative_paths_are_written_against_the_saved_file() { }; d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { - let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone()); + let d = cmd::links::with_relative_paths(&d, Default::default(), &dest).unwrap_or_else(|| d.clone()); match &d.node(nid).unwrap().kind { NodeKind::Image(im) => im.link.as_ref().unwrap().relative.clone(), _ => None, @@ -1083,15 +1218,11 @@ fn links_of_an_imported_file_are_read_where_they_are_found() { assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}"); } -/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the -/// file keeps relative to the document's (`src/`), and its link takes the size, modification time -/// and hash of the file found there. Not found, the link keeps no such details. -#[test] -fn links_of_an_imported_file_are_found_by_their_relative_folder() { +/// `poster.ai` in `dir`, a `.ai` file another app wrote, placing `src/photo.png` (relative to it) +/// under a path that no longer exists → its path. +fn imported_with_placed_file(dir: &Folder) -> String { use vectorcraft_testkit::ai; - let dir = Folder::new("imported-relative"); std::fs::create_dir_all(dir.0.join("src")).unwrap(); - let pic = dir.file("src/photo.png").replace('\\', "/"); let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n(/no/such/folder/src/photo.png)`\n\ %%IncludeFile:/no/such/folder/src/photo.png\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\ %AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\ @@ -1099,6 +1230,17 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { let doc_path = dir.file("poster.ai"); let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place)); write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f")); + doc_path +} + +/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the +/// file keeps relative to the document's (`src/`), and its link takes the size, modification time +/// and hash of the file found there. Not found, the link keeps no such details. +#[test] +fn links_of_an_imported_file_are_found_by_their_relative_folder() { + let dir = Folder::new("imported-relative"); + let pic = dir.file("src/photo.png").replace('\\', "/"); + let doc_path = imported_with_placed_file(&dir); let link = |s: &Session| { let mut found = vec![]; s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone())); @@ -1112,9 +1254,16 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { let (l, proxy) = link(&s); assert_eq!((l.path.as_str(), l.relative.as_deref()), ("/no/such/folder/src/photo.png", Some("src/photo.png"))); assert!(proxy && l.size.is_none() && l.modified.is_none() && l.hash.is_none(), "{l:?}"); + // Saved beside it while the file is missing, the document keeps the folder the file is in. + let native = dir.file("poster.vectorcraft"); + save(&mut s, &native); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png")); let bytes = png(600, 300, RED); write(&pic, &bytes); + let r = open(&mut s, &native); + assert_eq!(&r["missingLinks"], &json!([]), "the native copy finds the file: {r}"); let r = open(&mut s, &doc_path); assert_eq!(&r["missingLinks"], &json!([]), "{r}"); let (l, proxy) = link(&s); @@ -1123,3 +1272,27 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { assert_eq!((l.size, l.hash.clone()), (Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&bytes)))); assert!(l.modified.is_some(), "{l:?}"); } + +/// A Data Recovery copy of such a document keeps the file its links' relative paths name places +/// from: restored and saved beside the `.ai` file, it keeps the folder the placed file is in. +#[test] +fn a_recovered_copy_of_an_imported_file_keeps_its_links_folder() { + let dir = Folder::new("imported-recovered"); + let doc_path = imported_with_placed_file(&dir); + let store = std::sync::Arc::new(crate::cmd::recovery::MemoryStore::default()); + let mut s = session(); + s.recovery.set_store(store.clone()); + open(&mut s, &doc_path); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"].as_array().map(Vec::len), Some(1), "{r}"); + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store); + let r = s.execute("file.recovery.restore", &json!({})).unwrap(); + assert_eq!(r["restored"].as_array().map(Vec::len), Some(1), "{r}"); + let native = dir.file("poster.vectorcraft"); + save(&mut s, &native); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png")); +} diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index c7479ce8f..08fc120e0 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -537,6 +537,25 @@ fn envelope_top_object_maps_corners() { assert!(close(b.x0, 350.0) && close(b.y1, 250.0)); } +/// Each piece of a top object's side takes a share of the content in proportion to its control +/// polygon, spread by its curve parameter. The top side's two curves have polygons of 64.7 and +/// 166.5, so the content's middle lands on the second curve at t = 0.3056, (482.6, 106.4); by +/// length it would land at (493.7, 106.9), and by equal shares at the anchor (440, 100). +#[test] +fn envelope_top_object_shares_a_side_by_control_polygon_length() { + let mut s = session(); + let left = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let right = rect(&mut s, 200.0, 100.0, 100.0, 100.0); + let d = "M400 100 C410 80 430 80 440 100 C460 110 590 110 600 100 L600 200 L400 200 Z"; + let t = id_of(&s.execute("path.create", &json!({ "d": d })).unwrap()); + sel(&mut s, &[left, right, t]); + let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap()); + let pts = all_anchors(&live::expand_live(&node(&s, e))); + let near = |c: Point| pts.iter().map(|p| p.distance(c)).fold(f64::INFINITY, f64::min); + assert!(near(Point::new(482.6, 106.37)) < 0.5, "the middle of the top: {pts:?}"); + assert!(near(Point::new(500.0, 200.0)) < 0.5, "the middle of the bottom: {pts:?}"); +} + #[test] fn envelope_release_and_expand() { let mut s = session(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 4e6100df9..1bc75a82a 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -2104,7 +2104,7 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { let switch = |k: &str| format!("`{k}` must be true or false"); let scope = "`scope` must be one of selection, document".to_string(); let not_a_list = "ids must be an array of non-negative integer object ids".to_string(); - let not_an_id = "invalid object id \"x\": expected a non-negative integer".to_string(); + let not_an_id = |entry: usize| format!("`ids` entry {entry} is \"x\", not a non-negative integer object id"); for (cmd, p, want) in [ ("edit.findReplace", json!({"find": "cat", "replace": 5}), string("replace")), ("edit.findReplace", json!({"find": 5, "replace": "dog"}), string("find")), @@ -2112,7 +2112,8 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { ("edit.findReplace", json!({"find": "cat", "replace": "dog", "wholeWord": "yes"}), switch("wholeWord")), ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": t.0}), not_a_list), ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, 999_999]}), "no such object id 999999".to_string()), - ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id(0)), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, "x"]}), not_an_id(1)), ("edit.findNext", json!({"find": ["cat"]}), string("find")), ("edit.findNext", json!({"find": "cat", "matchCase": 1}), switch("matchCase")), ("edit.findNext", json!({"find": "cat", "wholeWord": "false"}), switch("wholeWord")), @@ -4462,7 +4463,7 @@ fn k_only(c: Color) -> f32 { fn document_color_mode_separates_rgb_greys_through_the_profile() { let (mut s, ids, _, _) = greys_document(); s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); - // As Illustrator converts them: four-colour greys and a rich black. + // Through the profile: a mid gray in four inks and a rich black. let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") }; assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}"); let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") }; diff --git a/crates/engine/src/tests/nativefile.rs b/crates/engine/src/tests/nativefile.rs index e3a5588b7..4156dc759 100644 --- a/crates/engine/src/tests/nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -222,7 +222,8 @@ fn a_version_1_documents_twist_turns_as_it_was_saved() { /// A save records the date the document was made in, so the same commands twice gave different /// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and -/// `SOURCE_DATE_EPOCH` for an export that takes no date param. +/// `SOURCE_DATE_EPOCH` for an export that takes no date param. The PDF a native file carries by +/// default is dated by the document's dates, not by the time of writing. #[test] fn a_pinned_date_makes_saves_byte_identical() { let once = |t: i64| { @@ -232,6 +233,9 @@ fn a_pinned_date_makes_saves_byte_identical() { saved(&mut s, json!({})) }; let at = 1_791_208_980; + let pdf = vectorcraft_format::pdf_content(&once(at)).expect("a native save carries a PDF by default"); + let info = vectorcraft_pdf::file_info(&pdf, None).unwrap(); + assert_eq!((info.created, info.modified), (Some(at), Some(at)), "the PDF has the document's dates"); assert_eq!(once(at), once(at), "the same document and date give the same bytes"); // The date really is in the bytes, so the comparison above is not vacuous. assert_ne!(once(at), once(at + 1), "another date shows in the bytes"); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 59b8db890..6c5c05572 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -133,6 +133,37 @@ fn white_black() -> Value { json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}) } +#[test] +fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it() { + // The object's own effect, and the fill's (the first appearance item). + let slant = json!({"corners": [[0.25, 0.0], [1.25, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + for (effect, params, item) in [ + ("distort.transform", json!({"rotate": 90}), json!(null)), + ("distort.transform", json!({"rotate": 30, "scaleH": 60}), json!(null)), + ("distort.transform", json!({"rotate": 90}), json!(0)), + ("distort.freeDistort", slant.clone(), json!(null)), + ("distort.freeDistort", slant, json!(0)), + ] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false); + // Placed from a quarter of the way across to the right edge. + run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false})); + let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() { + vectorcraft_color::Paint::Gradient(g) => g.geom, + _ => None, + }); + assert!(placed.is_some(), "the fill's gradient is placed"); + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + // Fill alone: the gradient becomes strips inside the outline the effect makes. + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + // The gradient moves with the rectangle on the canvas, and the strips follow it. + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}"); + } +} + #[test] fn eight_strips_match_the_gradient_samples() { let mut s = session(); @@ -894,11 +925,12 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() { 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. + // Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends + // the points' display colors and the mesh blends their inks, so the two draw a little apart in between. 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) { + for c in expanded(&mut s, 7.5) { 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}"); } diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index dcf3c6217..e15258bec 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -253,6 +253,103 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +/// A file missing from a document that moved keeps the place its relative path names from the +/// document's folder: the packaged document writes its relative path from there. +#[test] +fn a_missing_link_of_a_moved_document_keeps_its_relative_place() { + let a = Folder::new("package-moved-a"); + let b = Folder::new("package-moved-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::remove_file(b.file("art/blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + // Saved in another folder first: the relative path still names the folder the file was read from. + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + let out = b.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out})); + assert_eq!(r["missingLinks"], json!(["blue.png"]), "{r}"); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap(); + let mut rel = vec![]; + doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative)); + rel.sort(); + assert_eq!(rel, [Some("../../art/blue.png".to_string()), Some("Links/red.png".to_string())]); +} + +/// A missing link stays missing when another file sits where its relative path, written for the +/// package folder, would name from the folder the document was last saved in. +#[test] +fn a_missing_link_is_not_collected_from_another_place() { + let a = Folder::new("package-other-a"); + let b = Folder::new("package-other-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::remove_file(b.file("art/blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("s1/s2/s3")).unwrap(); + save(&mut s, &b.file("s1/s2/s3/poster.vectorcraft")); + // `../../art/blue.png` from the package folder, read from `s1/s2/s3`. + write(&b.file("s1/art/blue.png"), &png(10, 10, RED)); + let r = run(&mut s, "file.package", json!({"folder": b.file("out")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(1), json!(["blue.png"])), "{r}"); +} + +/// Without copying, a packaged document links a file found by name beside the document it was read +/// from, after a save in another folder, where it was found. +#[test] +fn a_link_found_by_name_is_kept_where_it_was_found_without_copying() { + let a = Folder::new("package-name-a"); + let b = Folder::new("package-name-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let away = b.file("away.png"); + std::fs::rename(b.file("art/blue.png"), &away).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + std::fs::rename(&away, b.file("blue.png")).unwrap(); + let out = b.file("out"); + run(&mut s, "file.package", json!({"folder": out, "copyLinks": false})); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let r = open(&mut s, &packaged.to_string_lossy()); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + +/// A file that comes back beside the document it was read from, after the document was saved in +/// another folder, is collected from there. +#[test] +fn a_link_that_comes_back_after_a_save_elsewhere_is_collected() { + let a = Folder::new("package-back-a"); + let b = Folder::new("package-back-b"); + let away = Folder::new("package-back-away"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.file("art/blue.png"), away.file("blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + std::fs::rename(away.file("blue.png"), b.file("art/blue.png")).unwrap(); + let out = b.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}"); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap(); + let mut rel = vec![]; + doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative)); + rel.sort(); + assert_eq!(rel, [Some("Links/blue.png".to_string()), Some("Links/red.png".to_string())]); + // A file beside the folder the document was last saved in is collected too. + std::fs::create_dir_all(b.0.join("saved/art")).unwrap(); + std::fs::rename(b.file("art/blue.png"), b.file("saved/art/blue.png")).unwrap(); + let r = run(&mut s, "file.package", json!({"folder": b.file("out2")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}"); +} + #[test] fn a_font_named_by_another_of_its_names_is_copied() { let dir = Folder::new("package-alias"); diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 0f95fc2f6..0870ffb87 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -119,6 +119,60 @@ fn artboards_reorder_and_duplicate() { assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second"); } +/// `artboard.new {beside, side}` adds an empty artboard the size of artboard `beside`, 20 pt from +/// that side of it, and `artboard.duplicate {index, side}` puts the copy and its art there; each is +/// one undo step. An unknown side, `beside` without `side`, and `side` with a size or place are +/// refused and change nothing. +#[test] +fn artboards_are_added_and_duplicated_on_a_side() { + let mut s = session(); + let id = rect(&mut s); + let board = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].rect; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let src = board(&s, 0); + let before = undo(&s); + assert_eq!(s.execute("artboard.new", &json!({"beside": 0, "side": "bottom"})).unwrap()["index"], 1); + assert_eq!(board(&s, 1), src + vectorcraft_geom::Vec2::new(0.0, src.height() + 20.0), "same size, 20 pt below"); + assert_eq!(undo(&s), before + 1, "one undo step"); + assert!(!s.doc().unwrap().doc.artboard_has_art(board(&s, 1)), "the new artboard is empty"); + s.execute("artboard.new", &json!({"beside": 0, "side": "left"})).unwrap(); + assert_eq!(board(&s, 2), src + vectorcraft_geom::Vec2::new(-(src.width() + 20.0), 0.0)); + s.execute("artboard.new", &json!({"side": "top"})).unwrap(); + assert_eq!(board(&s, 3), board(&s, 2) + vectorcraft_geom::Vec2::new(0.0, -(src.height() + 20.0)), "by default beside the last"); + s.execute("artboard.new", &json!({"side": "top", "beside": null, "x": null})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + + let before = undo(&s); + let out = s.execute("artboard.duplicate", &json!({"index": 0, "side": "right", "art": true})).unwrap(); + assert_eq!(out["index"], 4); + let dv = vectorcraft_geom::Vec2::new(src.width() + 20.0, 0.0); + assert_eq!(board(&s, 4), src + dv, "the copy sits 20 pt right of the artboard it copies"); + let copies: Vec = out["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 1); + let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert_eq!(bounds(&s, copies[0]), bounds(&s, id) + dv, "its art goes with it"); + assert_eq!(undo(&s), before + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 4, "undo takes the copy and its art away together"); + assert!(s.doc().unwrap().doc.node(copies[0]).is_none()); + + let before = s.doc().unwrap().doc.clone(); + let steps = undo(&s); + for (cmd, p, msg) in [ + ("artboard.new", json!({"beside": 0, "side": "middle"}), "`side` must be"), + ("artboard.new", json!({"beside": 0, "side": 3}), "`side` must be"), + ("artboard.new", json!({"beside": 0}), "`beside` needs `side`"), + ("artboard.new", json!({"side": "left", "width": 100}), "leave out x, y, width and height"), + ("artboard.new", json!({"beside": 9, "side": "left"}), "no such artboard"), + ("artboard.duplicate", json!({"index": 0, "side": "inside"}), "`side` must be"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{cmd} {p}: {e}"); + assert_eq!(*s.doc().unwrap().doc, *before, "{cmd} {p} changes nothing"); + assert_eq!(undo(&s), steps, "{cmd} {p}"); + } +} + /// An artboard's background color (Properties › Artboards › Fill): `artboard.setProps {color}` sets /// it and `{color: null}` clears it, each in one undo step. A change without `color` leaves it, a /// duplicate keeps it, `inspect_document` reports it, `artboard.new {color}` starts with it, an diff --git a/crates/engine/src/tests/paragraphs.rs b/crates/engine/src/tests/paragraphs.rs index 93cd13e57..5355cee5b 100644 --- a/crates/engine/src/tests/paragraphs.rs +++ b/crates/engine/src/tests/paragraphs.rs @@ -151,6 +151,42 @@ fn return_in_the_type_tool_continues_the_paragraph_it_splits() { assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]); } +#[test] +fn tab_at_the_start_of_a_list_item_moves_it_a_level_down_in_one_undo_step() { + let mut s = session(); + let v = ViewInfo::default(); + let id = area(&mut s, "one\ntwo"); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.select_tool("type", v).unwrap(); + // Click into the second line, then Home: the caret before "two". + let t = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let l1 = &lay.lines[1]; + let at = t.xf * vectorcraft_geom::Point::new((l1.x0 + l1.x1) / 2.0, l1.baseline - 3.0); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap(); + } + s.tool_key(ToolKey::Home, Mods::default(), v).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.tool_key(ToolKey::Tab, Mods::default(), v).unwrap(); + let levels = |s: &Session| text(s, id).paragraph_styles().iter().map(|p| p.list.map(|l| l.level)).collect::>(); + assert_eq!(levels(&s), [Some(1), Some(2)]); + assert_eq!((text(&s, id).plain_text(), s.doc().unwrap().history.undo.len()), ("one\ntwo".to_string(), undo + 1)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)); + let starts: Vec = lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].origin.x).collect(); + assert!((starts[1] - starts[0] - 18.0).abs() < 1e-6, "the item moved 18 pt in: {starts:?}"); + assert_eq!(lay.markers.iter().map(|m| m.text.as_str()).collect::>(), ["1.", "1."]); + // Shift+Tab moves it back up, and typing then goes on at the caret. + s.tool_key(ToolKey::Tab, Mods { shift: true, ..Mods::default() }, v).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1)]); + s.tool_text("and ", v).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(text(&s, id).plain_text(), "one\nand two"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((text(&s, id).plain_text(), levels(&s)), ("one\ntwo".to_string(), vec![Some(1), Some(2)])); +} + #[test] fn paragraph_style_applies_to_one_paragraph() { let mut s = session(); diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 544dcce96..c36d84fd9 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -979,6 +979,32 @@ fn picking_another_pattern_swatch_keeps_the_pattern_transform() { assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0]); } +/// A pattern swatch made from the current fill holds the pattern untransformed: a fill picked up +/// from art whose tiles were moved does not carry that placement to the art the swatch is applied +/// to, which keeps its own (#1970). +#[test] +fn a_pattern_swatch_made_from_a_transformed_fill_holds_the_pattern_untransformed() { + let mut s = session(); + make_dots(&mut s); + let src = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [src.0], "swatch": "Dots"})).unwrap(); + s.execute("pattern.transform", &json!({"ids": [src.0], "matrix": [2, 0, 0, 2, 15, 10]})).unwrap(); + sel(&mut s, &[]); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let Paint::Pattern { xf, .. } = &s.paint.fill else { panic!("the picked-up pattern fill") }; + assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0], "the current fill has the tiles' placement"); + s.execute("swatch.new", &json!({"name": "Picked"})).unwrap(); + let Paint::Pattern { pattern, xf } = doc(&s).swatch("Picked").unwrap().paint.clone() else { panic!("a pattern swatch") }; + assert_eq!((pattern.as_str(), xf.as_coeffs()), ("Dots", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + let dst = rect(&mut s, 300.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [dst.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [dst.0], "swatch": "Dots"})).unwrap(); + s.execute("pattern.transform", &json!({"ids": [dst.0], "matrix": [3, 0, 0, 3, 0, 0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [dst.0], "swatch": "Picked"})).unwrap(); + let Paint::Pattern { xf, .. } = node(&s, dst).appearance.fill_paint() else { panic!("a pattern fill") }; + assert_eq!(xf.as_coeffs(), [3.0, 0.0, 0.0, 3.0, 0.0, 0.0], "the art keeps its own placement"); +} + #[test] fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer() { // #2426: the glow's pixmap was drawn with the pattern's paint transform, so it landed diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index 4ca4b46fa..6a6022fdb 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -228,6 +228,40 @@ fn diagonal_shift_snap_lands_an_edge_on_a_gridline() { assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}"); } +#[test] +fn snap_moves_objects_at_different_depths_by_one_plane_offset() { + let (mut s, _) = persp_session(); + let rect = |s: &mut Session, x: f64| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()) + }; + let near = rect(&mut s, 240.0); + s.execute("perspective.attach", &json!({"ids": [near.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap(); + let far = rect(&mut s, 150.0); + s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap(); + s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap(); + assert_eq!(attached(&s, near), Some((Plane::Left, 0.0))); + assert_eq!(attached(&s, far), Some((Plane::Left, 50.0))); + s.execute("perspective.grid.snap", &json!({"on": true})).unwrap(); + let g = grid(&s); + let (near_before, far_before) = (plane_pts(&s, near, Plane::Left, 0.0), plane_pts(&s, far, Plane::Left, 50.0)); + let b = bounds_of(&near_before).union(bounds_of(&far_before)); + // A move that ends 0.3 pt past a gridline crossing on the near object's plane. + let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v; + let start = bounds_of(&near_before).center(); + let h = g.homography_at(Plane::Left, 0.0).unwrap(); + let from = h.apply(start).unwrap(); + let to = h.apply(start + Vec2::new(past(b.x0), past(b.y0))).unwrap(); + s.execute("perspective.move", &json!({"ids": [near.0, far.0], "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); + let near_delta = plane_pts(&s, near, Plane::Left, 0.0)[0] - near_before[0]; + let far_delta = plane_pts(&s, far, Plane::Left, 50.0)[0] - far_before[0]; + assert!((near_delta - far_delta).hypot() < 1e-6, "near {near_delta:?}, far {far_delta:?}"); + let on_line = |v: f64| ((v / g.cell).round() * g.cell - v).abs() < 1e-6; + let moved = b + near_delta; + assert!(on_line(moved.x0) || on_line(moved.x1), "{moved:?}"); + assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}"); +} + #[test] fn constrained_move_uses_each_objects_plane() { let (mut s, right) = persp_session(); diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs new file mode 100644 index 000000000..d348b13ae --- /dev/null +++ b/crates/engine/src/tests/pixelalign.rs @@ -0,0 +1,768 @@ +//! Transform panel › Align to Pixel Grid (`object.setPixelAligned`): a pixel-aligned path keeps its +//! horizontal and vertical segments on the pixel grid through transforms. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +/// A closed path from SVG path data `d`, selected, with a stroke of `weight` points. +fn path(s: &mut Session, d: &str, weight: f64) -> u64 { + let id = s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("stroke.set", &json!({"weight": weight})).unwrap(); + id +} + +/// The anchor points of path object `id`, every subpath's in order. +fn points(s: &Session, id: u64) -> Vec<(f64, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() +} + +fn aligned(s: &Session, id: u64) -> bool { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().pixel_aligned +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Whether every coordinate is a whole number of points (within rounding error). +fn whole(points: &[(f64, f64)]) -> bool { + points.iter().all(|(x, y)| (x - x.round()).abs() < 1e-9 && (y - y.round()).abs() < 1e-9) +} + +fn close(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 +} + +/// Turning it on sets the property and snaps the path's horizontal and vertical edges to whole +/// points, in one undo step; turning it on again changes nothing and makes no undo step; undo +/// puts the path and the property back. +#[test] +fn turning_it_on_snaps_the_path_in_one_undo_step() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let before = points(&s, id); + let steps = undo_len(&s); + let r = s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); + assert_eq!(r["ids"], json!([id])); + assert!(aligned(&s, id)); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(undo_len(&s), steps + 1); + let r = s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + assert_eq!(r["ids"], json!([])); + assert_eq!(undo_len(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!aligned(&s, id)); + assert_eq!(points(&s, id), before); + // An odd stroke weight centered on the path puts the edges on half points, and the stroke + // weight becomes whole. + s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); + s.execute("object.setPixelAligned", &json!({"on": true})).unwrap(); + assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); + // Off keeps the geometry. + s.execute("object.setPixelAligned", &json!({"on": false})).unwrap(); + assert!(!aligned(&s, id)); + assert_eq!(points(&s, id), [(10.5, 20.5), (40.5, 20.5), (40.5, 30.5), (10.5, 30.5)]); +} + +/// A pixel-aligned path stays on the pixel grid after a fractional move, a scale, a rotation about +/// a fractional point, a reflection, Transform Each and a move of its group; one that is not +/// moves exactly as before. +#[test] +fn it_stays_aligned_through_transforms() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let other = path(&mut s, "M10 121 L41 121 L41 130 L10 130 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.45})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert!(close(points(&s, other)[0], (10.3, 121.45)), "{:?}", points(&s, other)); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (cmd, p) in [ + ("object.move", json!({"dx": 5.7, "dy": -2.2})), + ("object.scale", json!({"sx": 133, "sy": 71})), + ("object.rotate", json!({"angle": 90, "origin": [20.2, 20.7]})), + ("object.reflect", json!({"axis": "horizontal", "origin": [0.3, 0.3]})), + ("object.transformEach", json!({"moveH": 0.4, "moveV": 0.35})), + ("object.setBounds", json!({"width": 17.3})), + ] { + s.execute(cmd, &p).unwrap(); + let pts = points(&s, id); + assert!(whole(&pts), "{cmd}: {pts:?}"); + let xs: Vec = pts.iter().map(|p| p.0).collect(); + assert!(xs.iter().any(|x| (x - xs[0]).abs() >= 1.0), "{cmd}: the path collapsed: {pts:?}"); + } + // In a group that moves. + s.execute("select.set", &json!({"ids": [id, other]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let before = points(&s, other); + s.execute("object.move", &json!({"dx": 0.25, "dy": 0.0})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + assert!(close(points(&s, other)[0], (before[0].0 + 0.25, before[0].1))); +} + +/// Art moved with its artboard stays on the pixel grid. +#[test] +fn it_stays_aligned_when_its_artboard_moves() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("artboard.move", &json!({"index": 0, "dx": 0.4, "dy": 0.3, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + s.execute("artboard.move", &json!({"index": 0, "dx": 0.6, "dy": 0.7, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(11.0, 22.0), (42.0, 22.0), (42.0, 31.0), (11.0, 31.0)]); +} + +/// Diagonal segments and curves keep their shape: only the anchors that horizontal and vertical +/// segments share move, each with its handles. +#[test] +fn diagonal_segments_and_curves_keep_their_shape() { + let mut s = new_doc(); + // A triangle: a horizontal base and two diagonal sides. Only the base's y snaps. + let tri = path(&mut s, "M0.2 0.2 L20.4 0.2 L10.3 15.7 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let pts = points(&s, tri); + assert!(close(pts[0], (0.2, 0.0)) && close(pts[1], (20.4, 0.0)) && close(pts[2], (10.3, 15.7)), "{pts:?}"); + s.execute("object.move", &json!({"dx": 0.5, "dy": 0.3})).unwrap(); + let pts = points(&s, tri); + assert!(close(pts[0], (0.7, 0.0)) && close(pts[1], (20.9, 0.0)) && close(pts[2], (10.8, 16.0)), "{pts:?}"); + // A curve between two horizontal segments: its ends move with them and its handles with its + // ends. + let curved = path(&mut s, "M100.3 100.3 L120.3 100.3 C125 105 125 115 120.3 120.3 L100.3 120.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(curved)).unwrap().clone(); + let sp = &n.path_data().unwrap().subpaths[0]; + let a = &sp.anchors; + assert!(close((a[0].p.x, a[0].p.y), (100.0, 100.0)) && close((a[3].p.x, a[3].p.y), (100.0, 120.0))); + assert!(close((a[1].p.x, a[1].p.y), (120.3, 100.0)) && close((a[1].h_out.x, a[1].h_out.y), (125.0, 104.7))); + assert!(close((a[2].p.x, a[2].p.y), (120.3, 120.0)) && close((a[2].h_in.x, a[2].h_in.y), (125.0, 114.7))); + // A rectangle turned 45° has no horizontal or vertical segment, and stays as it is. + let turned = path(&mut s, "M200.5 200 L210.5 210.3 L200.5 220.6 L190.5 210.3 Z", 2.0); + let before = points(&s, turned); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, turned), before); +} + +/// Live shapes stay live, and compound paths snap each of their paths. +#[test] +fn live_shapes_and_compound_paths_snap() { + let mut s = new_doc(); + let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone(); + assert!(n.live_shape().is_some()); + let b = n.geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); + s.execute("object.move", &json!({"dx": 0.4, "dy": 0.4})).unwrap(); + let b = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 21.0, 41.0, 30.0)); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + let pts: Vec<(f64, f64)> = + n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect(); + assert_eq!(pts.len(), 8); + assert!(whole(&pts), "{pts:?}"); +} + +/// The anchor points of every path in compound path `id`. +fn compound_points(s: &Session, id: u64) -> Vec<(f64, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.children().unwrap().iter().flat_map(|k| k.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>()).collect() +} + +/// A stroke edit on a pixel-aligned path rounds the new weight and snaps the path again: a 0.8 pt +/// weight becomes 1 pt with the edges on half points, a centered 3 pt stroke keeps the edges on +/// half points, and Stroke Weight Up keeps the weight whole; all in the stroke edit's one undo step. +#[test] +fn stroke_edits_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let steps = undo_len(&s); + s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(), 1.0); + assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert_eq!(undo_len(&s), steps + 1); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + let pts = points(&s, id); + assert!(pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9), "{pts:?}"); + // Inside alignment puts the stroke within the path, so the anchors go back to whole points. + s.execute("stroke.set", &json!({"align": "inside"})).unwrap(); + assert!(whole(&points(&s, id)), "{:?}", points(&s, id)); + s.execute("stroke.set", &json!({"align": "center", "weight": 0.6})).unwrap(); + s.execute("stroke.weightUp", &json!({})).unwrap(); + let w = s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.stroke_width(); + assert_eq!(w, w.round(), "{w}"); + // A path that is not pixel-aligned takes the weight as given. + let plain = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 0.8); + assert_eq!(s.doc().unwrap().doc.node(NodeId(plain)).unwrap().appearance.stroke_width(), 0.8); + assert_eq!(points(&s, plain), [(110.3, 20.6), (140.7, 20.6), (140.7, 30.3), (110.3, 30.3)]); +} + +/// Make Compound Path from pixel-aligned paths gives a pixel-aligned compound path whose paths do +/// not keep the property; they snap with the compound path's stroke, so a centered 1 pt stroke puts +/// them on half points after a move. Release gives each path the property back. +#[test] +fn a_compound_path_of_aligned_paths_is_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 1.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 1.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, c)); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|k| !k.extras().pixel_aligned)); + let half = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - x.floor() - 0.5).abs() < 1e-9 && (y - y.floor() - 0.5).abs() < 1e-9); + assert!(half(&compound_points(&s, c)), "{:?}", compound_points(&s, c)); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.2})).unwrap(); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 8); + assert!(half(&pts), "{pts:?}"); + // A path inside the compound path cannot take the property alone. + let member = n.children().unwrap()[0].id.0; + let e = s.execute("object.setPixelAligned", &json!({"ids": [member]})).unwrap_err().to_string(); + assert!(e.contains("only paths and compound paths"), "{e}"); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.compoundPath.release", &json!({})).unwrap(); + let released = s.doc().unwrap().selection.objects.clone(); + assert_eq!(released.len(), 2); + assert!(released.iter().all(|id| aligned(&s, id.0))); +} + +/// Make Compound Path from a pixel-aligned compound path and another path gives a pixel-aligned +/// compound path, which keeps snapping after a move. +#[test] +fn a_compound_path_of_an_aligned_compound_path_is_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100.2 100.2 L110.6 100.2 L110.6 110.6 L100.2 110.6 Z", 2.0); + let b = path(&mut s, "M200.2 100.2 L210.6 100.2 L210.6 110.6 L200.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + let other = path(&mut s, "M300.2 100.2 L310.6 100.2 L310.6 110.6 L300.2 110.6 Z", 2.0); + s.execute("select.set", &json!({"ids": [c, other]})).unwrap(); + let joined = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, joined)); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, joined); + assert_eq!(pts.len(), 12); + assert!(whole(&pts), "{pts:?}"); +} + +/// A stroke with no paint does not count: a fill-only path whose stroke item keeps an odd weight +/// snaps to whole points. +#[test] +fn a_stroke_without_paint_snaps_to_whole_points() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); + // An imported file can hold a stroke item without paint that keeps its weight. + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); + let Some(vectorcraft_doc::AppearanceItem::Stroke(st)) = + n.appearance.items.iter_mut().find(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))) + else { + panic!("no stroke"); + }; + st.paint = vectorcraft_color::Paint::None; + assert_eq!(st.width, 1.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// With direct-selected anchors in the selection, a move snaps the pixel-aligned path whose anchors +/// moved and the pixel-aligned object selected whole. +#[test] +fn moving_direct_selected_anchors_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 0.4, "dy": 0.0})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(points(&s, b), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); + s.execute("path.moveAnchors", &json!({"dx": 0.7, "dy": 0.0})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (42.0, 21.0), (42.0, 30.0), (10.0, 30.0)]); + assert_eq!(points(&s, b), [(111.0, 21.0), (142.0, 21.0), (142.0, 30.0), (111.0, 30.0)]); +} + +/// A path with Live Corners snaps the outline its corners are cut from, inner edges included, and +/// keeps its Live Corners. +#[test] +fn a_path_with_live_corners_snaps_its_inner_edges() { + let mut s = new_doc(); + let id = path(&mut s, "M10 10 L40 10 L40 20.3 L25 20.3 L25 40 L10 40 Z", 2.0); + s.execute("object.setLiveShape", &json!({"id": id, "radius": 2})).unwrap(); + let base = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("no Live Corners") }; + let vectorcraft_doc::LiveShape::Path { base, .. } = &**live else { panic!("not a path with Live Corners") }; + base.anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() + }; + assert!(base(&s).iter().any(|p| close(*p, (40.0, 20.3)))); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + assert_eq!(base(&s), [(10.0, 10.0), (40.0, 10.0), (40.0, 20.0), (25.0, 20.0), (25.0, 40.0), (10.0, 40.0)]); + let pts = points(&s, id); + assert!(pts.iter().all(|p| (p.1 - 20.3).abs() > 1e-6), "{pts:?}"); +} + +/// `path.setAnchors` and `object.setLiveShape` snap a pixel-aligned path again after they replace +/// its geometry. +#[test] +fn geometry_edits_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + let anchors = json!([{"x": 10.3, "y": 21.4}, {"x": 41.3, "y": 21.4}, {"x": 41.3, "y": 30.4}, {"x": 10.3, "y": 30.4}]); + s.execute("path.setAnchors", &json!({"id": id, "subpaths": [{"anchors": anchors, "closed": true}]})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let poly = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [poly]})).unwrap(); + s.execute("object.setLiveShape", &json!({"id": poly, "sides": 5})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(poly)).unwrap().clone(); + let b = n.geometric_bounds().unwrap(); + let on_grid = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9 || (v - v.round()).abs() < 1e-9; + assert!(on_grid(b.y1), "{b:?}"); +} + +/// Any other edit of a pixel-aligned path, here Object › Path › Average, snaps it again too. +#[test] +fn other_path_edits_keep_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let b = path(&mut s, "M60.3 50 L80 50 L80 60 L60.3 60 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("path.average", &json!({"axis": "vertical"})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (47.0, 21.0), (47.0, 30.0), (10.0, 30.0)]); + assert!((points(&s, b)[0].0 - (41.0 + 41.0 + 60.3) / 3.0).abs() < 1e-9, "{:?}", points(&s, b)); +} + +/// A stroke edit that Global Edit carries to the other members snaps the pixel-aligned ones. +#[test] +fn global_edit_keeps_its_members_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + // The same shape at twice the size. + let b = path(&mut s, "M110 21 L172 21 L172 39 L110 39 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a, b]})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(r["active"], true); + s.execute("stroke.set", &json!({"weight": 1})).unwrap(); + assert_eq!(strokes(&s, b)[0].1, 1.0); + assert_eq!(points(&s, a), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert!(halves(&points(&s, b)), "{:?}", points(&s, b)); +} + +/// Align with direct-selected anchors snaps the pixel-aligned path whose anchors moved. +#[test] +fn aligning_direct_selected_anchors_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let b = path(&mut s, "M60.4 50.3 L80 50.3 L80 60 L60.4 60 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 1], [0, 2]], "mode": "add"})).unwrap(); + s.execute("select.anchors", &json!({"id": b, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); + assert_eq!(points(&s, a), [(10.0, 21.0), (60.0, 21.0), (60.0, 30.0), (10.0, 30.0)]); + assert!(close(points(&s, b)[0], (60.4, 50.3)), "{:?}", points(&s, b)); +} + +/// The art of a linked opacity mask moves and scales with a path that snaps, and a placed gradient +/// keeps its place too. +#[test] +fn a_linked_opacity_mask_follows_a_snap() { + let mut s = new_doc(); + let o = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); + let mask_bounds = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); + n.extras().mask.clone().expect("an opacity mask").art.geometric_bounds().unwrap() + }; + let (from, mask) = (s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(), mask_bounds(&s)); + s.execute("object.setPixelAligned", &json!({"ids": [o]})).unwrap(); + let to = s.doc().unwrap().doc.node(NodeId(o)).unwrap().geometric_bounds().unwrap(); + assert_eq!(points(&s, o), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let at = |r: vectorcraft_geom::Rect, x: f64, y: f64| ((x - r.x0) / r.width(), (y - r.y0) / r.height()); + let after = mask_bounds(&s); + assert!(close(at(to, after.x0, after.y0), at(from, mask.x0, mask.y0)), "{after:?} from {mask:?}"); + assert!(close(at(to, after.x1, after.y1), at(from, mask.x1, mask.y1)), "{after:?} from {mask:?}"); +} + +/// `path.moveAnchor` (the Anchor Point tool, Smart Pen) snaps a pixel-aligned path again: two moves +/// that leave a horizontal segment off the grid put it back on whole points. +#[test] +fn moving_one_anchor_keeps_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + s.execute("path.moveAnchor", &json!({"id": id, "anchor": 0, "x": 10, "y": 21.3})).unwrap(); + s.execute("path.moveAnchor", &json!({"id": id, "anchor": 1, "x": 41, "y": 21.3})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// A path of a pixel-aligned compound path moved on its own (Group Selection) snaps with the +/// compound path. +#[test] +fn moving_a_path_of_an_aligned_compound_path_keeps_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 2.0); + let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 2.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(c)).unwrap().clone(); + let member = n.children().unwrap()[0].id.0; + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 8); + assert!(whole(&pts), "{pts:?}"); +} + +/// A path that Paste in Front puts inside a pixel-aligned compound path, or that an object slice +/// move moves there, snaps with the compound path's stroke, and a pasted path drops its own +/// property there. +#[test] +fn paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned() { + let mut s = new_doc(); + let a = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); + let b = path(&mut s, "M200 100 L210 100 L210 110 L200 110 Z", 1.0); + let loose = path(&mut s, "M300 100 L310 100 L310 110 L300 110 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [loose]})).unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + assert!(halves(&compound_points(&s, c))); + let member = s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap()[0].id.0; + s.execute("select.set", &json!({"ids": [loose]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [member]})).unwrap(); + s.execute("edit.pasteInFront", &json!({})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0]; + assert_eq!(s.doc().unwrap().doc.parent_of(pasted), Some(NodeId(c))); + assert!(!aligned(&s, pasted.0)); + let pts = compound_points(&s, c); + assert_eq!(pts.len(), 12); + assert!(halves(&pts), "paste: {pts:?}"); + s.execute("object.slice.make", &json!({"ids": [member]})).unwrap(); + s.execute("object.slice.move", &json!({"slices": [member], "dx": 0.3, "dy": 0.4})).unwrap(); + let pts = compound_points(&s, c); + assert!(halves(&pts), "slice: {pts:?}"); +} + +/// Art moved by Artboard Options with Move Artwork with Artboard, and the art copies of an +/// artboard copied with its art, stay on the pixel grid. +#[test] +fn artboard_options_and_artboard_copies_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + let r = s.doc().unwrap().doc.artboards[0].rect; + s.execute("artboard.setProps", &json!({"index": 0, "x": r.x0 + 0.4, "y": r.y0 + 0.3, "moveArt": true})).unwrap(); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + let out = s.execute("artboard.move", &json!({"index": 0, "dx": 500.4, "dy": 0.3, "copy": true, "moveArt": true})).unwrap(); + let copies = out["moved"].as_array().unwrap(); + assert_eq!(copies.len(), 1); + let copy = copies[0].as_u64().unwrap(); + assert!(aligned(&s, copy)); + assert!(whole(&points(&s, copy)), "{:?}", points(&s, copy)); + assert_eq!(points(&s, id), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); +} + +/// Type, images and groups cannot be aligned: the command is an error and nothing changes, also +/// for a path selected with one of them. +#[test] +fn type_and_images_cannot_be_aligned() { + let mut s = new_doc(); + let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let before = points(&s, p); + let text = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + let png = crate::tests_links::png(4, 4, [255, 0, 0]); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + let image = { + let st = s.doc().unwrap(); + let id = st.selection.objects[0]; + assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Image(_))); + id.0 + }; + let steps = undo_len(&s); + for ids in [json!([text]), json!([image]), json!([p, text])] { + let e = s.execute("object.setPixelAligned", &json!({"ids": ids})).unwrap_err().to_string(); + assert!(e.contains("only paths and compound paths"), "{ids}: {e}"); + assert_eq!(undo_len(&s), steps, "{ids}"); + assert!(!aligned(&s, p)); + assert_eq!(points(&s, p), before); + } + let e = s.execute("object.setPixelAligned", &json!({"ids": [p], "on": "yes"})).unwrap_err().to_string(); + assert!(e.contains("`on` must be true or false"), "{e}"); + assert_eq!(undo_len(&s), steps); +} + +/// The property is saved in the native file and read back; an object without it is saved without +/// the key and reads back off. +#[test] +fn the_property_is_saved_and_read_back() { + let mut s = new_doc(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert!(back.node(NodeId(a)).unwrap().extras().pixel_aligned); + assert!(!back.node(NodeId(b)).unwrap().extras().pixel_aligned); + let plain: Value = serde_json::to_value(d.node(NodeId(b)).unwrap()).unwrap(); + assert!(plain.get("pixel_aligned").is_none(), "{plain}"); + let node = s.execute("document.node", &json!({"id": a})).unwrap(); + assert_eq!(node["pixel_aligned"], true, "{node}"); +} + +/// A copy of a pixel-aligned path that Duplicate or Paste moves by a fractional offset is snapped +/// to the pixel grid and keeps the property, and so is a pixel-aligned object slice that moves. +#[test] +fn duplicates_and_pastes_keep_it_aligned() { + let mut s = new_doc(); + let id = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap(); + let copy = s.doc().unwrap().selection.objects[0].0; + assert_ne!(copy, id); + assert!(aligned(&s, copy)); + assert_eq!(points(&s, copy), [(110.0, 21.0), (141.0, 21.0), (141.0, 30.0), (110.0, 30.0)]); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({"dx": 0.3, "dy": 50.4})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0].0; + assert!(pasted != id && pasted != copy); + assert!(aligned(&s, pasted)); + assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); + // Moving an object slice moves its object, which snaps too. + s.execute("object.slice.make", &json!({"ids": [pasted]})).unwrap(); + s.execute("object.slice.move", &json!({"slices": [pasted], "dx": 0.3, "dy": 0.4})).unwrap(); + assert_eq!(points(&s, pasted), [(10.0, 71.0), (41.0, 71.0), (41.0, 80.0), (10.0, 80.0)]); +} + +/// Pixel-aligned art of a linked opacity mask snaps when its object moves. +#[test] +fn the_art_of_a_linked_opacity_mask_keeps_it_aligned() { + let mut s = new_doc(); + let o = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + let m = path(&mut s, "M12 23 L39 23 L39 28 L12 28 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [m]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({"ids": [o, m]})).unwrap(); + let mask_points = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(o)).unwrap().clone(); + let mask = n.extras().mask.clone().expect("an opacity mask"); + assert!(mask.art.extras().pixel_aligned); + mask.art.path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect::>() + }; + s.execute("select.set", &json!({"ids": [o]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + assert_eq!(mask_points(&s), [(12.0, 23.0), (39.0, 23.0), (39.0, 28.0), (12.0, 28.0)]); + // The masked path inside a pixel-aligned compound path. + let other = path(&mut s, "M110 21 L141 21 L141 30 L110 30 Z", 2.0); + s.execute("select.set", &json!({"ids": [o, other]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [c]})).unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.4})).unwrap(); + assert!(whole(&mask_points(&s)), "{:?}", mask_points(&s)); +} + +/// The start point of path object `id`'s placed gradient fill. +fn gradient_start(s: &Session, id: u64) -> (f64, f64) { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let Some(vectorcraft_color::Paint::Gradient(g)) = n.appearance.paint_at(None, true) else { panic!("no gradient fill") }; + let p = g.geom.expect("a placed gradient").start; + (p.x, p.y) +} + +/// A placed gradient moves with a pixel-aligned path when a stroke edit snaps it. +#[test] +fn placed_gradients_follow_the_snapped_path() { + let mut s = new_doc(); + let id = path(&mut s, "M10 21 L41 21 L41 30 L10 30 Z", 2.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [id]})).unwrap(); + { + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).unwrap(); + let bounds = n.geometric_bounds().unwrap(); + let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| i.is_fill()) else { panic!("no fill") }; + let vectorcraft_color::Paint::Gradient(g) = &mut f.paint else { panic!("no gradient fill") }; + g.pin(bounds); + } + s.execute("object.setPixelAligned", &json!({"ids": [id]})).unwrap(); + let before = gradient_start(&s, id); + let i = strokes(&s, id)[0].0; + s.execute("appearance.setItem", &json!({"ids": [id], "index": i, "weight": 1})).unwrap(); + assert_eq!(points(&s, id), [(10.5, 21.5), (41.5, 21.5), (41.5, 30.5), (10.5, 30.5)]); + assert!(close(gradient_start(&s, id), (before.0 + 0.5, before.1 + 0.5)), "{:?} from {before:?}", gradient_start(&s, id)); +} + +/// The paint-order indices of path object `id`'s strokes, and their weights. +fn strokes(s: &Session, id: u64) -> Vec<(usize, f64)> { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + n.appearance + .items + .iter() + .enumerate() + .filter_map(|(i, it)| match it { + vectorcraft_doc::AppearanceItem::Stroke(st) => Some((i, st.width)), + _ => None, + }) + .collect() +} + +/// Whether every coordinate is a whole number of points plus a half (within rounding error). +fn halves(points: &[(f64, f64)]) -> bool { + points.iter().all(|(x, y)| ((x - 0.5) - (x - 0.5).round()).abs() < 1e-9 && ((y - 0.5) - (y - 0.5).round()).abs() < 1e-9) +} + +/// Appearance edits that add, remove, hide, repaint or reweigh a stroke snap a pixel-aligned path +/// again: Appearance panel item edits, Remove Item, Add New Stroke, Clear Appearance, Reduce to +/// Basic Appearance, Show All Hidden Attributes, moving or copying an appearance, +/// `paint.setStroke`, a graphic style and the Eyedropper. The edges go to half +/// points while a visible, painted centered stroke has an odd weight, otherwise to whole points. +#[test] +fn appearance_edits_keep_it_aligned() { + let mut s = new_doc(); + let b = path(&mut s, "M110.3 20.6 L140.7 20.6 L140.7 30.3 L110.3 30.3 Z", 2.2); + let c = path(&mut s, "M210.3 20.6 L240.7 20.6 L240.7 30.3 L210.3 30.3 Z", 1.0); + s.execute("graphicStyle.new", &json!({"id": c, "name": "One"})).unwrap(); + let a = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 2.0); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a))); + let top = |s: &Session| strokes(s, a).last().map(|(i, _)| *i).unwrap(); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 3.3})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 3.0); + assert!(halves(&points(&s, a)), "weight: {:?}", points(&s, a)); + // Hiding and showing the stroke again puts the path back where it was. + let shown = points(&s, a); + for _ in 0..2 { + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); + assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": true})).unwrap(); + assert_eq!(points(&s, a), shown); + } + s.execute("paint.setStroke", &json!({"ids": [a], "none": true})).unwrap(); + assert!(whole(&points(&s, a)), "no paint: {:?}", points(&s, a)); + s.execute("paint.setStroke", &json!({"ids": [a], "color": "#00ff00"})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 1.0); + assert!(halves(&points(&s, a)), "painted: {:?}", points(&s, a)); + let i = top(&s); + s.execute("appearance.removeItem", &json!({"ids": [a], "index": i})).unwrap(); + assert!(strokes(&s, a).is_empty()); + assert!(whole(&points(&s, a)), "removed: {:?}", points(&s, a)); + s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); + assert!(halves(&points(&s, a)), "added: {:?}", points(&s, a)); + s.execute("appearance.clear", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a)), "cleared: {:?}", points(&s, a)); + s.execute("graphicStyle.apply", &json!({"ids": [a], "name": "One"})).unwrap(); + assert!(halves(&points(&s, a)), "style: {:?}", points(&s, a)); + s.execute("appearance.copyFrom", &json!({"source": b, "ids": [a]})).unwrap(); + assert_eq!(strokes(&s, a).last().unwrap().1, 2.0); + assert!(whole(&points(&s, a)), "eyedropper: {:?}", points(&s, a)); + // A 1 pt stroke below a 2 pt one: half points, and whole points once Reduce to Basic Appearance + // keeps only the top stroke. + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [a]})).unwrap(); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 2})).unwrap(); + assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [1.0, 2.0]); + assert!(halves(&points(&s, a)), "two strokes: {:?}", points(&s, a)); + s.execute("appearance.reduceToBasic", &json!({"ids": [a]})).unwrap(); + assert_eq!(strokes(&s, a).iter().map(|(_, w)| *w).collect::>(), [2.0]); + assert!(whole(&points(&s, a)), "reduced: {:?}", points(&s, a)); + // Show All Hidden Attributes, and moving or copying an appearance onto or off the path. + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "visible": false})).unwrap(); + assert!(whole(&points(&s, a)), "hidden: {:?}", points(&s, a)); + s.execute("appearance.showAllHidden", &json!({"ids": [a]})).unwrap(); + assert!(halves(&points(&s, a)), "show all: {:?}", points(&s, a)); + s.execute("appearance.transfer", &json!({"source": b, "target": a, "copy": true})).unwrap(); + assert!(whole(&points(&s, a)), "copied onto: {:?}", points(&s, a)); + let i = top(&s); + s.execute("appearance.setItem", &json!({"ids": [a], "index": i, "weight": 1})).unwrap(); + assert!(halves(&points(&s, a)), "1 pt: {:?}", points(&s, a)); + s.execute("appearance.transfer", &json!({"source": a, "target": c})).unwrap(); + assert!(whole(&points(&s, a)), "moved off: {:?}", points(&s, a)); + // A path that is not aligned keeps its coordinates through the same edits. + let before = points(&s, b); + s.execute("appearance.setItem", &json!({"ids": [b], "index": strokes(&s, b)[0].0, "weight": 3.3})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [b]})).unwrap(); + assert_eq!(points(&s, b), before); +} + +#[test] +fn patterns_follow_the_snap_with_transform_patterns() { + let mut s = new_doc(); + let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [tile], "color": "#ff0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [tile]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + let a = path(&mut s, "M 100 100 L 150 100 L 150 150 L 100 150 Z", 2.0); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Dots"})).unwrap(); + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + let tiles = |s: &Session| { + let n = s.doc().unwrap().doc.node(NodeId(a)).unwrap().clone(); + let vectorcraft_color::Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!("no pattern fill") }; + xf.as_coeffs() + }; + let (before, xf) = (points(&s, a), tiles(&s)); + // The move snaps the path back to where it was, and its tiles go back with it. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0.0, "patterns": true})).unwrap(); + assert_eq!(points(&s, a), before); + assert!(tiles(&s).iter().zip(xf).all(|(t, x)| (t - x).abs() < 1e-9), "tiles: {:?}, before {xf:?}", tiles(&s)); + // A move that the snap keeps moves the tiles as far as the path. + s.execute("object.move", &json!({"dx": 0.7, "dy": 0.0, "patterns": true})).unwrap(); + assert_eq!(points(&s, a)[0], (before[0].0 + 1.0, before[0].1)); + assert!((tiles(&s)[4] - (xf[4] + 1.0)).abs() < 1e-9, "tiles: {:?}, before {xf:?}", tiles(&s)); +} + +#[test] +fn objects_on_path_members_stay_on_the_grid() { + let mut s = new_doc(); + let spine = s.execute("path.create", &json!({"d": "M 0.3 100.4 L 300.3 100.4"})).unwrap()["id"].as_u64().unwrap(); + let a = path(&mut s, "M 10 10 L 30 10 L 30 30 L 10 30 Z", 2.0); + s.execute("object.objectsOnPath.attach", &json!({"path": spine, "ids": [a]})).unwrap(); + assert!(!whole(&points(&s, a)), "placed: {:?}", points(&s, a)); + // Placing the member on the path after the edit must not move it off the grid again. + s.execute("object.setPixelAligned", &json!({"ids": [a]})).unwrap(); + assert!(whole(&points(&s, a)), "aligned: {:?}", points(&s, a)); + // Editing the path places the member again, and it goes back on the grid. + let anchors = json!([{"x": 0.6, "y": 120.7}, {"x": 300.6, "y": 120.7}]); + s.execute("path.setAnchors", &json!({"id": spine, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap(); + assert!(whole(&points(&s, a)), "path edited: {:?}", points(&s, a)); + assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a)); +} diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 7616a4529..5871b85a3 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -474,6 +474,26 @@ fn update_links_on_open_reads_a_modified_file_again() { assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); } +/// A placed document whose link keeps no size or hash and whose file's bytes are gone shows no +/// preview: resolving its links reports the file as modified and leaves the object as it is. +#[test] +fn a_placed_document_without_its_bytes_or_a_hash_is_reported_not_read() { + let dir = Folder::new("placed-no-hash"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let key = placed(&s, id).key; + let mut d = (*s.doc().unwrap().doc).clone(); + d.images.remove(&key); + let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("a placed document") }; + p.link.hash = None; + p.link.size = None; + let r = crate::cmd::links::resolve(&mut d, crate::cmd::links::LinkBase::file(None), false, false).to_json(); + assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(!d.images.contains_key(&key), "not read"); +} + #[test] fn relink_points_a_placed_document_at_another_file() { let dir = Folder::new("placed-relink"); @@ -663,6 +683,78 @@ fn saving_and_reopening_keeps_a_linked_pdf_pages_number_and_box() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// A placed PDF page in a `.ai` file's editing data is a placed document of the page and box the +/// dictionary after it names. Its file missing, it shows the preview the file keeps, in the same +/// place. Its file found in the folder kept relative to the document, it shows that page. +#[test] +fn a_pdf_page_placed_in_an_ai_files_editing_data_opens_as_a_placed_document() { + use vectorcraft_testkit::ai; + let dir = Folder::new("placed-pdf-in-ai"); + let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n(/no/such/folder/src/flyer.pdf)`\n\ + %%IncludeFile:/no/such/folder/src/flyer.pdf\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\ + %AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\ + 2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\ + %_/ArtDictionary :\n%_2 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n"; + let doc_path = dir.file("poster.ai"); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place)); + // The page draws what the half-covering preview draws over white. + write(&doc_path, &ai::ai(data.as_bytes(), "0.54 0.54 0.95 rg 20 20 60 60 re f")); + let shown = |s: &Session| { + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true); + let [r, g, b, _] = img.pixel(50, 50); + [r, g, b] + }; + let one = |s: &Session| { + let mut found = vec![]; + s.doc().unwrap().doc.visit_placed(|n, p| found.push((n.id, p.clone()))); + let [(id, p)] = &found[..] else { panic!("one placed document: {found:?}") }; + (*id, p.clone(), s.doc().unwrap().doc.images[&p.key].is_proxy()) + }; + let mut s = session(); + + let r = open(&mut s, &doc_path); + assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("flyer.pdf"), "{r}"); + let (id, p, proxy) = one(&s); + assert!(proxy, "the preview"); + assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false)); + assert_eq!(p.link.relative.as_deref(), Some("src/flyer.pdf")); + let place = bounds(&s, id); + assert!((place.x0 - 20.0).abs() < 0.01 && (place.width() - 60.0).abs() < 0.01 && (place.height() - 60.0).abs() < 0.01, "{place:?}"); + assert!(near(shown(&s), [138, 138, 243]), "the preview shows: {:?}", shown(&s)); + + pdf_source(&dir.file("src/flyer.pdf"), "pdf", &[BLUE, RED], 0); + let r = open(&mut s, &doc_path); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + let (id, p, proxy) = one(&s); + assert!(!proxy, "the file's bytes, not the preview"); + assert_eq!((p.link.page, p.page_box, p.bounding), (Some(2), vectorcraft_doc::PageBox::Media, false)); + assert!(p.link.hash.is_some() && p.link.size.is_some(), "{:?}", p.link); + assert_eq!(bounds(&s, id), place, "the same place"); + assert!(near(shown(&s), RED), "page 2 of the file shows: {:?}", shown(&s)); +} + +/// A `.ai` saved without PDF compatibility (its page only text) that places itself as a page is +/// read once inside itself: the copy inside keeps its link as stored, and the file opens. +#[test] +fn an_ai_file_that_places_itself_opens() { + use vectorcraft_testkit::ai; + let dir = Folder::new("ai-places-itself"); + let doc_path = dir.file("poster.ai"); + let at = doc_path.replace('\\', "/"); + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 600 4 4 0\n({at})`\n%%IncludeFile:{at}\n%AI26_BeginPlacedObjectPreview\n\ + 2 1 8 32 17 \x7f\x0a\x0a\x73\x7f\x0a\x0a\x73\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n\ + %_/ArtDictionary :\n%_1 /Int (AI11 PlacedPDFPageIndex) ,\n%_4 /Int (AI11 PlacedPDFCropTo) ,\n%_;\n%_\n" + ); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{place}", ai::rect(120.0, 20.0, 180.0, 80.0)))); + write(&doc_path, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET")); + let mut s = session(); + let r = open(&mut s, &doc_path); + let mut found = vec![]; + s.doc().unwrap().doc.visit_placed(|_, p| found.push(p.link.path.replace('\\', "/"))); + assert_eq!(found, [at], "{r}"); +} + #[test] fn a_pdf_compatible_ai_file_places_linked_like_a_pdf() { let dir = Folder::new("placed-ai-linked"); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fdfe60067..dad65195f 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -17,8 +17,9 @@ fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } +/// Installs `wasm`, in place of any module installed with its id (`replace: true`). fn install(s: &mut Session, wasm: &[u8]) -> Value { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})) + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true})) } fn install_wat(s: &mut Session, src: &str) -> Value { @@ -74,6 +75,52 @@ fn object_filter_recolours_the_selection_as_one_undo_step() { assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. }))); } +/// `plugin.run` refuses an `ids` list with an entry that is not an object id, with an error naming +/// the key, the entry and its value, and changes nothing. With `ids` left out or null the filter +/// runs on the selection, as it does on the list of the selection. +#[test] +fn object_filter_ids_entries_must_be_ids_and_ids_left_out_take_the_selection() { + // A session with the plug-in, a red and a blue rectangle, and the red one selected. The + // document has no creation date, so documents made in different seconds compare equal. + let fresh = || { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 200, "created": null})); + install(&mut s, DESATURATE); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 40.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [a.0]})); + (s, a, b) + }; + let (_, a, b) = fresh(); + for (bad, entry) in [(json!(["abc"]), r#"entry 0 is "abc""#), (json!([a.0, true]), "entry 1 is true"), (json!([{}]), "entry 0 is {}")] { + let (mut s, ..) = fresh(); + let st = s.doc().unwrap(); + let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len()); + let r = s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": 100, "ids": bad})); + let want = format!("`ids` {entry}, not a non-negative integer object id"); + assert!(matches!(&r, Err(EngineError::BadParams { cmd, msg }) if cmd == "plugin.run" && *msg == want), "{bad}: {r:?}"); + let st = s.doc().unwrap(); + assert!((st.doc.clone(), st.selection.clone(), st.history.undo.len()) == before, "{bad}: something changed"); + } + let gray = |s: &Session, id: NodeId| { + let [r, g, b] = node(s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated(); + (r - g).abs() < 1e-6 && (g - b).abs() < 1e-6 + }; + let mut docs = vec![]; + for ids in [None, Some(Value::Null), Some(json!([a.0]))] { + let (mut s, ..) = fresh(); + let mut p = json!({"id": DESATURATE_ID, "amount": 100}); + if let Some(ids) = &ids { + p["ids"] = ids.clone(); + } + let r = run(&mut s, "plugin.run", p); + assert_eq!(r["ids"], json!([a.0]), "{ids:?}"); + assert!(gray(&s, a) && !gray(&s, b), "{ids:?}: only the selected rectangle turns gray"); + docs.push(s.doc().unwrap().doc.clone()); + } + assert!(docs.windows(2).all(|w| w[0] == w[1]), "left out, null and the selection's list give the same document"); +} + #[test] fn object_filters_add_replace_and_generate_objects() { let mut s = session(); @@ -532,8 +579,8 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec { /// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), /// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's -/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same -/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the +/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one +/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the /// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. #[test] fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { @@ -574,15 +621,15 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { // grant now records the new module's hash. let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); // One that asks for more is refused until it is granted all of them. let more = data(r#"["doc.read","doc.write"]"#); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true); - let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": more, "replace": true})).unwrap_err().to_string(); assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}"); assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays"); - run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]})); + run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"], "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded"); // Removed, it is asked again. run(&mut s, "plugin.remove", json!({"id": id})); @@ -603,7 +650,7 @@ fn a_filter_does_only_what_its_permissions_allow() { let id = rect(&mut s, 10.0, "#ff0000"); let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]})); let grant = |s: &mut Session, wasm: &[u8], perms: Value| { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms})); + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms, "replace": true})); }; // Denied `doc.write`: renaming the object is refused, and the document and history stay. let reader = "test.engine.permissions.reader"; @@ -747,6 +794,59 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "plugin.remove", json!({"id": id})); } +/// A grant covers the module it was given to: another module under the id, listing the same +/// permissions, isn't installed from the store at the next start, a folder load reports it as +/// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It +/// doesn't get the settings kept for the id until the user grants it. A kept grant without a hash +/// covers no module. +#[test] +fn a_grant_covers_only_the_module_it_was_given_to() { + let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); + let id = "test.engine.granthash"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + // The stored module is replaced on disk by one that reads the settings. + let other = storing(id, READS); + let stored: Vec<_> = + std::fs::read_dir(&folder.0).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "wasm")).collect(); + assert_eq!(stored.len(), 1, "{stored:?}"); + std::fs::write(&stored[0], &other).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant"); + let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); + std::fs::write(dir.0.join("other.wasm"), &other).unwrap(); + let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([])); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval"), "{why}"); + let data = vectorcraft_format::base64_encode(&other); + let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "{e}"); + // Granted, it reads the settings the first module set. + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // A kept grant that records no hash, or an empty one, covers no module: the stored module isn't + // installed at the next start and `plugin.inspect` asks for approval. + for entry in [json!({"id": id, "permissions": ["settings", "cache"]}), json!({"id": id, "hash": "", "permissions": ["settings", "cache"]})] { + std::fs::write(folder.0.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut again = session(); + again.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "a grant without a hash doesn't load the module: {entry}"); + assert_eq!(run(&mut again, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], json!(true), "{entry}"); + next = again; + } + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is /// skipped without dropping the other grants, and recording a grant writes it back as it was, /// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a @@ -888,7 +988,7 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() { let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#); let install = |s: &mut Session, src: String| { let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); - run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true})); + run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": true})); }; // It first keeps the pens it found with the document. install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string())); @@ -954,3 +1054,379 @@ fn a_cancelled_filter_run_changes_nothing() { assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); assert_eq!(s.doc().unwrap().doc, before); } + +/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first` +/// (instructions that trap unless each storage call returns what it should), with the keys "zoom" +/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100. +fn storing(id: &str, first: &str) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS); + wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap() +} + +/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile". +const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable)) + (if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))"; + +/// Traps unless "zoom" is "hello". +const READS: &str = + "(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))"; + +fn install_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"]})) +} + +/// [`install_storing`] in place of another module with the same id (`replace: true`). +fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})) +} + +/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session +/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache` +/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is +/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], +/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since +/// clearing every plug-in's cache would empty theirs. +#[test] +fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { + let folder = crate::tests_links::Folder::new("plugin-storage"); + let (id, other) = ("test.engine.storing", "test.engine.storing.other"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let dir = crate::store::plugin_data_dir(&folder.0, id); + assert!(dir.join("settings.json").is_file()); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // A restart: installed again from the store, and replaced by a module that reads. + vectorcraft_plugins::registry::remove(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + let listed = run(&mut next, "plugin.list", json!({})); + let entry = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + assert_eq!(entry["cacheBytes"], json!(3)); + assert_eq!(replace_storing(&mut next, id, READS)["cacheBytes"], json!(3)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // Another plug-in has settings of its own: "zoom" isn't set there. + install_storing(&mut next, other, READS); + assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err()); + + // Clearing the cache leaves the settings. + assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); + assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + replace_storing(&mut next, other, WRITES); + run(&mut next, "plugin.run", json!({"id": other, "ids": []})); + let all = run(&mut next, "plugin.clearCache", json!({})); + let cleared = all["cleared"].as_array().unwrap(); + assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}"); + assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0)); + assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err()); + + run(&mut next, "plugin.remove", json!({"id": id})); + assert!(!dir.exists(), "removed with the plug-in"); + run(&mut next, "plugin.remove", json!({"id": other})); + plugin_storage_is_capped_and_survives_a_damaged_settings_file(); + plugin_storage_calls_are_charged_the_files_they_read_and_write(); +} + +/// A `storage` folder in the plug-in store that is a link leads out of the store: the plug-in +/// installs without `replace` although a folder for its id is there, its storage calls fail as +/// unavailable, and removing the plug-in deletes nothing the link leads to. +#[test] +fn plugin_storage_behind_a_link_is_left_alone() { + let folder = crate::tests_links::Folder::new("plugin-storage-link"); + let outside = crate::tests_links::Folder::new("plugin-storage-outside"); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&outside.0, folder.0.join("storage")).is_ok(); + // Without Developer Mode or elevation Windows makes no symbolic link, but a junction. + #[cfg(windows)] + let made = std::os::windows::fs::symlink_dir(&outside.0, folder.0.join("storage")).is_ok() + || std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(folder.0.join("storage")) + .arg(&outside.0) + .output() + .is_ok_and(|o| o.status.success()); + #[cfg(not(any(unix, windows)))] + let made = false; + if !made { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + let id = "test.engine.storagelink"; + let kept = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + std::fs::create_dir_all(kept.parent().unwrap()).unwrap(); + std::fs::write(&kept, b"{}").unwrap(); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + assert_eq!(st.set_setting("zoom", b"hello", m), Err(vectorcraft_plugins::storage::StorageError::Unavailable)); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(kept.is_file(), "left where the link leads"); +} + +/// `plugin.remove` with the id of a plug-in that isn't installed deletes the settings and cache the +/// plug-in store still keeps for it; with neither, it fails as before. +#[test] +fn removing_an_id_that_is_not_installed_deletes_its_storage() { + let folder = crate::tests_links::Folder::new("plugin-storage-orphan"); + let id = "test.engine.storageorphan"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::write(dir.join("settings.json"), b"{}").unwrap(); + std::fs::write(dir.join("cache").join("tile.bin"), b"abc").unwrap(); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + assert_eq!(run(&mut s, "plugin.remove", json!({"id": id})), json!({"removed": id})); + assert!(!dir.exists(), "the storage of an id that isn't installed is deleted"); + assert!(s.execute("plugin.remove", &json!({"id": id})).is_err(), "nothing left to remove"); +} + +/// The settings file and the cache are checked: a file that doesn't read is moved aside and the +/// settings start empty; a write past a cap fails and changes nothing; cache entries whose names +/// differ only in case are two files; a removed plug-in's storage takes no more writes. +fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-caps"); + let id = "test.engine.storagecaps"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(&dir).unwrap(); + let m = &mut Meter::unlimited(); + + // A truncated settings file. + std::fs::write(dir.join("settings.json"), br#"{"zoom": "aGVsbG"#).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + st.set_setting("zoom", b"hello", m).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"hello".to_vec())); + // Damaged again, it replaces the file moved aside before. + std::fs::write(dir.join("settings.json"), b"[").unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert_eq!(std::fs::read(dir.join("settings.unreadable.json")).unwrap(), b"["); + st.set_setting("zoom", b"hello", m).unwrap(); + plugin_storage_writes_are_atomic(&*st, &dir, &folder.0); + + // At most 256 keys: a new one is refused, an existing one still changes. + let keys: std::collections::BTreeMap = (0..storage::MAX_SETTINGS).map(|i| (format!("k{i}"), "eA==".to_string())).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("one-more", b"x", m), Err(StorageError::Full)); + st.set_setting("k0", b"y", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(b"y".to_vec())); + plugin_storage_skips_oversize_files(&*st, &dir); + + // At most 1 MiB in all: eleven values of 64 KiB fit, a twelfth doesn't, and nothing changes. + let big = vec![7_u8; storage::MAX_SETTING_BYTES]; + let keys: std::collections::BTreeMap = (0..11).map(|i| (format!("k{i}"), vectorcraft_format::base64_encode(&big))).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("k11", &big, m), Err(StorageError::Full)); + assert_eq!((st.setting("k11", m).unwrap(), st.setting("k0", m).unwrap()), (None, Some(big.clone()))); + + // The cache: entry size, then the total, here nearly reached by a file already there. + assert_eq!(st.set_cache_entry("big", &vec![0; storage::MAX_CACHE_ENTRY + 1], m), Err(StorageError::Full)); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::File::create(dir.join("cache").join("filler.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES - 2).unwrap(); + // A temporary file a failed write left behind counts toward no cap, and clearing removes it. + let leftover = dir.join("cache").join(".tile.bin.1-0.tmp"); + std::fs::write(&leftover, b"tmp").unwrap(); + assert_eq!(st.set_cache_entry("tile", b"abc", m), Err(StorageError::Full)); + assert_eq!(st.cache_entry("tile", m).unwrap(), None); + st.set_cache_entry("tile", b"ab", m).unwrap(); + assert_eq!(st.cache_bytes(), storage::MAX_CACHE_BYTES); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!leftover.exists()); + + // Names differing in case are two entries. + st.set_cache_entry("Tile", b"1", m).unwrap(); + st.set_cache_entry("tile", b"22", m).unwrap(); + assert_eq!((st.cache_entry("Tile", m).unwrap(), st.cache_entry("tile", m).unwrap()), (Some(b"1".to_vec()), Some(b"22".to_vec()))); + assert_eq!(st.cache_bytes(), 3); + + // More items in the cache folder than a write looks at: the write is refused whatever the + // items it looked at hold, and clearing still reaches every entry. + let cache = dir.join("cache"); + for i in 0..crate::store::MAX_CACHE_SCAN { + std::fs::create_dir(cache.join(format!("d{i:05}"))).unwrap(); + } + std::fs::File::create(cache.join("z.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES).unwrap(); + assert_eq!(st.set_cache_entry("more", b"1", m), Err(StorageError::Full)); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!cache.join("z.bin").exists()); + + // An id that differs only in case has storage of its own, which removing this one leaves. + let upper_id = "test.engine.StorageCaps"; + s.attach_plugin_storage(upper_id); + let upper = vectorcraft_plugins::registry::storage(upper_id).unwrap(); + assert_eq!(upper.setting("k0", m).unwrap(), None); + upper.set_setting("k0", b"upper", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(big)); + // So do ids whose `_` and `.` could be read as each other's escapes. + let folders: std::collections::BTreeSet<_> = + ["a.b", "a_0b", "a_b", "A.b", "a__b", "a._b", "a_.b"].iter().map(|id| crate::store::plugin_data_dir(&folder.0, id)).collect(); + assert_eq!(folders.len(), 7, "{folders:?}"); + + // Removing the plug-in closes the storage a run still holds, even one from before the plug-in + // was replaced, so nothing is written back. + s.attach_plugin_storage(id); + run(&mut s, "plugin.remove", json!({"id": id})); + assert_eq!(st.set_setting("zoom", b"again", m), Err(StorageError::Unavailable)); + assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable)); + assert!(!dir.exists()); + assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec())); + s.remove_plugin_storage(upper_id).unwrap(); +} + +/// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although +/// each of its values would fit, is moved aside and the settings start empty; a cache entry file +/// larger than an entry may be fails as unavailable. `st` is the storage in `dir`. +fn plugin_storage_skips_oversize_files(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path) { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let m = &mut Meter::unlimited(); + let value = vectorcraft_format::base64_encode(&vec![7_u8; storage::MAX_SETTING_BYTES]); + let keys: std::collections::BTreeMap = (0..13).map(|i| (format!("k{i}"), value.clone())).collect(); + let oversize = serde_json::to_vec(&keys).unwrap(); + assert!(oversize.len() > storage::MAX_SETTINGS_BYTES); + std::fs::write(dir.join("settings.json"), &oversize).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + let huge = dir.join("cache").join("huge.bin"); + std::fs::File::create(&huge).unwrap().set_len(storage::MAX_CACHE_ENTRY as u64 + 1).unwrap(); + assert_eq!(st.cache_entry("huge", m), Err(StorageError::Unavailable)); + std::fs::remove_file(&huge).unwrap(); +} + +/// Writes are atomic: a new file takes the old one's name, so another name for the old file (a hard +/// link) keeps its content. `st` is the storage in `dir` and holds the setting "zoom"; `outside` is +/// a folder outside it. +fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path, outside: &std::path::Path) { + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + let settings_copy = outside.join("settings-copy.json"); + let before = std::fs::read(dir.join("settings.json")).unwrap(); + std::fs::hard_link(dir.join("settings.json"), &settings_copy).unwrap(); + st.set_setting("zoom", b"world", m).unwrap(); + assert_eq!(std::fs::read(&settings_copy).unwrap(), before); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"world".to_vec())); + st.set_cache_entry("tile", b"abc", m).unwrap(); + let entry_copy = outside.join("tile-copy.bin"); + let entry = std::fs::read_dir(dir.join("cache")).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_file()).unwrap(); + std::fs::hard_link(&entry, &entry_copy).unwrap(); + st.set_cache_entry("tile", b"xyz", m).unwrap(); + assert_eq!(std::fs::read(&entry_copy).unwrap(), b"abc"); + assert_eq!(st.cache_entry("tile", m).unwrap(), Some(b"xyz".to_vec())); + assert!(st.remove_cache_entry("tile").unwrap()); + std::fs::remove_file(&settings_copy).unwrap(); + std::fs::remove_file(&entry_copy).unwrap(); +} + +/// Each storage call is charged the bytes it reads and writes, before it does: a settings call the +/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or +/// writes. A call that would pass what its meter has left fails and changes nothing. +fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { + use vectorcraft_plugins::storage::{Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-meter"); + let id = "test.engine.storagemeter"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let file = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + let size = || std::fs::metadata(&file).unwrap().len(); + st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap(); + st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap(); + + // Reading a one-byte setting reads the whole file. + let before = size(); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec())); + assert_eq!(m.used(), before); + // Setting it reads the file and writes it again, and so does deleting one. + let mut m = Meter::unlimited(); + st.set_setting("small", b"yz", &mut m).unwrap(); + assert_eq!(m.used(), before + size()); + let (before, mut m) = (size(), Meter::unlimited()); + assert!(st.delete_setting("small", &mut m).unwrap()); + assert_eq!(m.used(), before + size()); + st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap(); + + // Less left than the file: nothing is read. Enough to read it but not to write it again: + // nothing changes. + let mut m = Meter::new(size() - 1); + assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget)); + assert_eq!(m.used(), 0); + assert_eq!(st.set_setting("small", b"z", &mut Meter::new(size())), Err(StorageError::Budget)); + assert_eq!(st.setting("small", &mut Meter::unlimited()).unwrap(), Some(b"y".to_vec())); + + // A cache write is charged the entry it writes, a read the entry it reads. + let mut m = Meter::unlimited(); + st.set_cache_entry("tile", &[1; 1000], &mut m).unwrap(); + assert_eq!(m.used(), 1000); + let mut m = Meter::unlimited(); + assert_eq!(st.cache_entry("tile", &mut m).unwrap().map(|v| v.len()), Some(1000)); + assert_eq!(m.used(), 1000); + assert_eq!(st.cache_entry("tile", &mut Meter::new(999)), Err(StorageError::Budget)); + assert_eq!(st.set_cache_entry("tile", &[2; 10], &mut Meter::new(9)), Err(StorageError::Budget)); + assert_eq!(st.cache_entry("tile", &mut Meter::unlimited()).unwrap().map(|v| v.len()), Some(1000)); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A module with other bytes than the plug-in installed with its id, or, with none installed, one +/// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and +/// then keeps them; the same module installs again without it, and `replace` must be true or false. +#[test] +fn another_module_under_an_id_with_settings_needs_replace() { + let folder = crate::tests_links::Folder::new("plugin-storage-replace"); + let id = "test.engine.storingreplace"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let hash = run(&mut s, "plugin.info", json!({"id": id}))["hash"].clone(); + // The same module again. + install_storing(&mut s, id, WRITES); + + let reads = vectorcraft_format::base64_encode(&storing(id, READS)); + let install = |s: &mut Session, extra: Value| { + let mut p = json!({"dataBase64": reads, "grant": ["settings", "cache"]}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("plugin.install", &p) + }; + let Err(e) = install(&mut s, json!({})) else { panic!("another module took the plug-in's place") }; + assert!(e.to_string().contains(&format!("“Test {id}” is another module than the plug-in with id \"{id}\"")), "{e}"); + assert!(e.to_string().contains("`replace: true`"), "{e}"); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(true)); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["hash"], hash); + for bad in [json!({"replace": "yes"}), json!({"replace": 1})] { + let Err(e) = install(&mut s, bad) else { panic!("a bad `replace` was taken") }; + assert!(e.to_string().contains("`replace` must be true or false"), "{e}"); + } + assert!(install(&mut s, json!({"replace": false})).is_err()); + install(&mut s, json!({"replace": true})).unwrap(); + // It reads what the one it replaced set. + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // Not installed, but with settings in the store: another module needs `replace` too. + vectorcraft_plugins::registry::remove(id); + let writes = vectorcraft_format::base64_encode(&storing(id, WRITES)); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": writes}))["needsReplace"], json!(true)); + assert!(s.execute("plugin.install", &json!({"dataBase64": writes, "grant": ["settings", "cache"]})).is_err()); + run(&mut s, "plugin.install", json!({"dataBase64": writes, "grant": ["settings", "cache"], "replace": true})); + run(&mut s, "plugin.remove", json!({"id": id})); + // Once removed, with its settings and cache, it installs as a first install does. + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(false)); + install(&mut s, json!({})).unwrap(); + run(&mut s, "plugin.remove", json!({"id": id})); +} diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 437701136..52755f2cb 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -7,6 +7,32 @@ use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; use crate::*; +/// The saved preference reaches the real tool; older preferences keep Touching and invalid +/// values fail without changing the current setting. +#[test] +fn marquee_selection_preference_drives_the_selection_tool() { + assert_eq!(serde_json::from_value::(json!({})).unwrap().marquee_selection, "touching"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (mode, selected) in [("touching", true), ("enclosed", false), ("directional", false)] { + set_pref(&mut s, "marqueeSelection", json!(mode)); + s.execute("select.none", &json!({})).unwrap(); + for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 150.0), (PointerKind::Up, 150.0, 150.0)] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + assert_eq!(!s.doc().unwrap().selection.objects.is_empty(), selected, "{mode}"); + let persisted = s.prefs.to_json(); + assert_eq!(serde_json::from_value::(persisted).unwrap().marquee_selection, mode); + } + assert!(s.execute("prefs.set", &json!({"key": "marqueeSelection", "value": "unknown"})).is_err()); + assert_eq!(s.prefs.marquee_selection, "directional"); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.marquee_selection, "touching"); +} + #[test] fn every_spec_matches_a_prefs_field_and_back() { let all = Prefs::default().to_json(); @@ -2511,6 +2537,20 @@ fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() { } } +/// General › Scroll Sideways with Cmd/Ctrl + Wheel starts on: in a new session, in a preferences +/// file saved before the option existed and after a reset. +#[test] +fn scroll_sideways_with_cmd_wheel_is_on_by_default() { + assert!(Prefs::default().ctrl_wheel_scrolls_sideways); + let old: Prefs = serde_json::from_value(json!({"zoomWithMouseWheel": false})).unwrap(); + assert!(old.ctrl_wheel_scrolls_sideways); + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "ctrlWheelScrollsSideways", "value": false})).unwrap(); + assert!(!s.prefs.ctrl_wheel_scrolls_sideways); + s.execute("prefs.reset", &json!({})).unwrap(); + assert!(s.prefs.ctrl_wheel_scrolls_sideways); +} + /// The Transform panel menu's choice starts as `default` (the panels follow General › Transform /// Pattern Tiles): in a new session, in a preferences file saved before the choice existed and /// after a reset; `prefs.set` takes it and the three menu choices, and nothing else. diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 56c8d899d..9fe2d846c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -59,6 +59,57 @@ fn restored(r: serde_json::Value) -> usize { r["restored"].as_array().unwrap().len() } +#[test] +fn native_recovery_copies_leave_out_the_pdf() { + let (mut s, store) = session(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", r["saved"][0]["file"].as_str().unwrap()); + let bytes = store.read(&file).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), s.doc().unwrap().doc.node_count()); +} + +#[test] +fn restored_recovery_copies_keep_the_original_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-pdf-preference"); + for pdf in [false, true] { + let (mut s, store) = session(); + s.execute("file.saveAs", &json!({"path": dir.file(&format!("original-{pdf}.vectorcraft")), "pdfCompatible": pdf})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + let copied = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", copied["saved"][0]["file"].as_str().unwrap()); + assert!(vectorcraft_format::pdf_content(&store.read(&file).unwrap()).is_none()); + drop(s); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(pdf))); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&bytes).is_some(), pdf); + } +} + +#[test] +fn recovered_unsaved_documents_keep_inheriting_the_current_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-inherited-pdf-choice"); + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file("other.vectorcraft"),"pdfCompatible":false})).unwrap(); + assert!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().is_empty()); + let latest_preferences = s.prefs.clone(); + drop(s); + let mut s = relaunch(&store); + // The host loads its latest preferences before offering recovery. + s.prefs = latest_preferences; + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); +} + #[test] fn recovery_save_writes_modified_documents_once() { let (mut s, store) = session(); diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index 9324b1f4b..a5b19563a 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -157,7 +157,146 @@ fn pdf_compatible_files_carry_a_pdf() { assert!(pdf.starts_with(b"%PDF")); let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); assert_eq!(info["pages"], 3); - assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none()); + assert!( + vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": false})).unwrap()["dataBase64"])).is_none() + ); +} + +/// A native save is portable to the other apps by default. The option is still an explicit +/// opt-out, and a remembered opt-out survives later saves of the document. +#[test] +fn native_saves_carry_all_artboards_as_pdf_by_default() { + let mut s = three_boards(); + for compress in [false, true] { + let result = s.execute("file.saveCopy", &json!({"compress": compress})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let pdf = vectorcraft_format::pdf_content(&file).expect("the default native save carries a PDF"); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + assert_eq!(info["pages"], 3); + assert_eq!(vectorcraft_format::load(&file).unwrap().artboards.len(), 3); + } + let dir = Folder::new("pdf-opt-out"); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + let options = s.execute("file.formatOptions", &json!({"format":"vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({"compress": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], false); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.save", &json!({"pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); +} + +#[test] +fn native_saves_without_artboards_keep_the_editable_document() { + let mut s = three_boards(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let restored = vectorcraft_format::load(&file).unwrap(); + assert!(restored.artboards.is_empty()); + assert_eq!(restored.node_count(), s.doc().unwrap().doc.node_count()); + assert!(vectorcraft_format::pdf_content(&file).is_none()); + s.execute("document.open", &json!({"name": "no-artboards.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], true); + s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&result["dataBase64"])).is_some()); +} + +#[test] +fn native_template_saves_keep_the_document_pdf_preference() { + let mut s = three_boards(); + let dir = Folder::new("template-pdf-preference"); + s.execute("file.saveAs", &json!({"path": dir.file("compact.vectorcraft"), "pdfCompatible": false, "version": 3})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "template"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false, "the save dialog must offer the same retained choice"); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template"})).unwrap(); + let bytes = bytes_of(&r["dataBase64"]); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!( + vectorcraft_format::load_file(&bytes).unwrap().info.version, + vectorcraft_format::VERSION, + "unrelated format options are not carried over" + ); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template", "pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&r["dataBase64"])).is_some()); +} + +#[test] +fn a_default_native_save_keeps_its_pdf_after_another_document_opts_out() { + let dir = Folder::new("default-native-pdf-choice"); + for (index, options) in [json!({}), json!({"options":{"pdfCompatible":null}})].into_iter().enumerate() { + let mut s = three_boards(); + let path = dir.file(&format!("first-{index}.vectorcraft")); + let mut params = options.as_object().unwrap().clone(); + params.insert("path".into(), json!(path)); + s.execute("file.saveAs", &Value::Object(params)).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file(&format!("other-{index}.vectorcraft")),"pdfCompatible":false})).unwrap(); + s.execute("document.activate", &json!({"index":0})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), "the first document keeps the PDF it was saved with"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(true))); + } +} + +#[test] +fn a_null_nested_pdf_option_keeps_the_saved_opt_out() { + let dir = Folder::new("null-nested-pdf-option"); + let mut s = three_boards(); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("document.save", &json!({"options": {"pdfCompatible": null}})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none(), "null leaves the saved choice"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(false))); +} + +#[test] +fn reopening_native_files_keeps_each_files_pdf_compatibility() { + let dir = Folder::new("pdf-reopened-options"); + let mut s = three_boards(); + let pdf = dir.file("portable.vectorcraft"); + let compact = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": pdf, "pdfCompatible": true})).unwrap(); + s.execute("file.saveAs", &json!({"path": compact, "pdfCompatible": false})).unwrap(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + let copy = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(©["dataBase64"])).is_none(), "new files use the last native preference"); + for (path, expected) in [(&pdf, true), (&compact, false)] { + s.execute("document.open", &json!({"path": path})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], expected); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(path).unwrap()).is_some(), expected); + } +} + +#[test] +fn reverting_native_files_updates_their_pdf_compatibility() { + let dir = Folder::new("pdf-revert-options"); + for saved in [false, true] { + let mut s = three_boards(); + let path = dir.file(if saved { "with-pdf.vectorcraft" } else { "compact.vectorcraft" }); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": saved})).unwrap(); + let replacement = s.execute("file.saveCopy", &json!({"pdfCompatible": !saved})).unwrap(); + std::fs::write(&path, bytes_of(&replacement["dataBase64"])).unwrap(); + s.execute("shape.rectangle", &json!({"x": 70, "y": 20, "width": 8, "height": 8})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], !saved); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), !saved); + } } #[test] @@ -429,7 +568,7 @@ fn format_options_list_the_save_options() { assert_eq!(v["options"][k]["value"], value, "{k}"); } let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); - assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true)); + assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(true), true)); // Saved with them, a document remembers them for Save. let dir = Folder::new("remember"); let path = dir.file("r.vectorcraft"); diff --git a/crates/engine/src/tests/select_ids_first.rs b/crates/engine/src/tests/select_ids_first.rs index da6c83c87..35d5165cd 100644 --- a/crates/engine/src/tests/select_ids_first.rs +++ b/crates/engine/src/tests/select_ids_first.rs @@ -208,7 +208,7 @@ fn id_errors_name_the_command_run() { for (given, want) in [ (json!([999]), "no such object id 999".to_string()), (json!([999, layer]), "no such object id 999".to_string()), - (json!([ids[0], ids[1], -1]), "invalid object id -1: expected a non-negative integer".to_string()), + (json!([ids[0], ids[1], -1]), "`ids` entry 2 is -1, not a non-negative integer object id".to_string()), (json!(ids[0]), "ids must be an array of non-negative integer object ids".to_string()), ] { let mut p = match *cmd { diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs new file mode 100644 index 000000000..fcb4581c2 --- /dev/null +++ b/crates/engine/src/tests/svgfilter.rs @@ -0,0 +1,495 @@ +//! Effect › SVG Filters (`svgFilter.*`): the filters a document holds, applying one as a live +//! `svg.filter` effect, and what SVG export and the native format keep of them. + +use serde_json::{Value, json}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).unwrap_err().to_string() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()) +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn names(s: &mut Session) -> Vec { + run(s, "svgFilter.list", json!({}))["filters"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap().to_string()).collect() +} + +const BLUR: &str = r#""#; +const SHIFT: &str = r#""#; + +#[test] +fn filters_are_defined_edited_and_deleted_in_one_undo_step_each() { + let mut s = session(); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"], json!([])); + assert_eq!(run(&mut s, "svgFilter.define", json!({"xml": format!(" {BLUR} ")}))["name"], "Soft"); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + assert_eq!(names(&mut s), ["Soft", "Shift"], "in the order they were added"); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], BLUR, "the XML trimmed"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Soft"], "one step"); + run(&mut s, "edit.redo", json!({})); + // A name another filter has is refused; replace edits one in place, and it keeps its name. + assert!(err(&mut s, "svgFilter.define", json!({"xml": BLUR})).contains("already has an SVG filter named \"Soft\"")); + let renamed = r#""#; + run(&mut s, "svgFilter.define", json!({"xml": renamed, "replace": "Soft"})); + assert_eq!(names(&mut s), ["Soft", "Shift"]); + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Nope"})).contains("no SVG filter named \"Nope\"")); + // The same filter again changes nothing and makes no undo step. + let before = steps(&s); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Shift"})); + assert_eq!(steps(&s), before); + assert_eq!(run(&mut s, "svgFilter.delete", json!({"name": "Soft"}))["filters"].as_array().unwrap().len(), 1); + assert!(err(&mut s, "svgFilter.delete", json!({"name": "Soft"})).contains("no SVG filter named")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": " "})).contains("give `name`")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Soft", "Shift"]); +} + +#[test] +fn define_refuses_what_is_not_one_filter() { + let mut s = session(); + for (p, why) in [ + (json!({}), "give `xml`"), + (json!({"xml": 3}), "`xml` must be a string"), + (json!({"xml": ""}), "needs an id"), + (json!({"xml": ""}), "no primitives"), + (json!({"xml": ""}), "give one element"), + (json!({"xml": SHIFT, "replace": 1}), "`replace` must be a string"), + ] { + let e = err(&mut s, "svgFilter.define", p.clone()); + assert!(e.contains(why), "{p}: {e}"); + } + assert_eq!(steps(&s), 0, "nothing recorded"); +} + +#[test] +fn a_document_holds_at_most_64_filters() { + let mut s = session(); + for i in 0..64 { + run(&mut s, "svgFilter.define", json!({"xml": format!("")})); + } + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT})).contains("at most 64 SVG filters")); + run(&mut s, "svgFilter.define", json!({"xml": "", "replace": "f0"})); + assert_eq!(names(&mut s).len(), 64, "a replacement still fits"); +} + +#[test] +fn import_adds_the_files_filters_it_does_not_have() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + let svg = format!( + "{BLUR}" + ); + let path = std::env::temp_dir().join(format!("vc-svgfilters-{}.svg", std::process::id())); + std::fs::write(&path, &svg).unwrap(); + let r = run(&mut s, "svgFilter.import", json!({"path": path.to_string_lossy()})); + std::fs::remove_file(&path).ok(); + assert_eq!(r["added"], json!(["Soft"])); + assert_eq!(r["skipped"], 2, "a name the document has, and a filter without primitives"); + assert_eq!(names(&mut s), ["Shift", "Soft"]); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], SHIFT, "the document's own filter stays"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Shift"], "one step"); + // Nothing new: no undo step. + let before = steps(&s); + assert_eq!(run(&mut s, "svgFilter.import", json!({"svg": ""}))["added"], json!([])); + assert_eq!(steps(&s), before); + assert!(err(&mut s, "svgFilter.import", json!({"svg": "{}{BLUR}{}", "".repeat(5000), "".repeat(5000)); + assert!(err(&mut s, "svgFilter.import", json!({"svg": deep})).contains("isn't valid SVG")); + assert!(err(&mut s, "svgFilter.import", json!({})).contains("give `path`")); + let svg_text = format!("{BLUR}"); + assert!(err(&mut s, "svgFilter.import", json!({"path": 3, "svg": svg_text})).contains("`path` must be a string")); + assert!(err(&mut s, "svgFilter.import", json!({"svg": ["x"]})).contains("`svg` must be a string")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": 3})).contains("`name` must be a string")); + let svgz = std::env::temp_dir().join(format!("vc-svgfilters-{}.svgz", std::process::id())); + std::fs::write(&svgz, vectorcraft_svg::compress(&format!("{BLUR}"))).unwrap(); + let e = err(&mut s, "svgFilter.import", json!({"path": svgz.to_string_lossy()})); + std::fs::remove_file(&svgz).ok(); + assert!(e.contains("not SVGZ"), "{e}"); + let missing = std::env::temp_dir().join("vc-no-such-svgfilter-file (1).svg"); + assert!(s.execute("svgFilter.import", &json!({"path": missing.to_string_lossy()})).is_err()); +} + +#[test] +fn apply_adds_the_filter_as_a_live_effect_in_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.define", json!({"xml": BLUR})); + let before = steps(&s); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let r = run(&mut s, "svgFilter.apply", json!({"name": "Soft"})); + assert_eq!(r["ids"].as_array().unwrap().len(), 2); + assert_eq!(steps(&s), before + 1, "one step for both objects"); + for id in [a, b] { + let fx = s.doc().unwrap().doc.node(id).unwrap().appearance.effects.clone(); + assert_eq!(fx.len(), 1); + assert_eq!(fx[0].id, "svg.filter"); + assert_eq!((fx[0].params["name"].as_str(), fx[0].params["xml"].as_str()), (Some("Soft"), Some(BLUR))); + } + run(&mut s, "svgFilter.delete", json!({"name": "Soft"})); + assert!(err(&mut s, "svgFilter.apply", json!({"name": "Soft"})).contains("no SVG filter named")); + assert!(err(&mut s, "svgFilter.apply", json!({})).contains("give `name`")); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("svgFilter.apply", &json!({"name": "Shift"})).is_err(), "nothing selected"); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [b.0]})); + assert_eq!(s.doc().unwrap().doc.node(b).unwrap().appearance.effects.len(), 2, "given ids, nothing selected"); +} + +/// The xml the first effect of `id` draws with. +fn drawn(s: &Session, id: NodeId) -> Value { + s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["xml"].clone() +} + +const SHIFT_MORE: &str = r#""#; + +/// Objects name the document's filter: an edit changes what every one of them draws, in one undo +/// step, and a later application takes the edited filter. +#[test] +fn editing_a_filter_changes_every_object_that_uses_it() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + let c = rect(&mut s, 90.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0, b.0]})); + let before = steps(&s); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + assert_eq!(steps(&s), before + 1); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT_MORE), json!(SHIFT_MORE))); + // One undo puts both back. + run(&mut s, "edit.undo", json!({})); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(SHIFT), json!(SHIFT))); + run(&mut s, "edit.redo", json!({})); + // Applied after the edit: the edited filter. + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [c.0]})); + assert_eq!(drawn(&s, c), json!(SHIFT_MORE)); + // An edit whose code names another id keeps the filter's name. + run(&mut s, "svgFilter.define", json!({"xml": r#""#, "replace": "Shift"})); + assert_eq!(names(&mut s), ["Shift"]); + assert_eq!(drawn(&s, a), json!(r#""#)); +} + +/// Deleting a filter that objects use leaves their effects, which draw nothing until a filter of +/// that name exists again. +#[test] +fn deleting_a_filter_in_use_leaves_the_effect_drawing_nothing() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": "Shift"})); + let fx = s.doc().unwrap().doc.node(a).unwrap().appearance.effects.clone(); + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Shift")), "the effect stays, named"); + assert!(vectorcraft_render::effects::raster_effects(&fx).is_empty(), "drawn without the filter"); + // Imported again: the effect draws with it. + run(&mut s, "svgFilter.import", json!({"svg": format!("{SHIFT_MORE}")})); + assert_eq!(drawn(&s, a), json!(SHIFT_MORE)); +} + +/// Art in a symbol's definition that uses a filter follows its edits too. +#[test] +fn a_symbols_art_follows_a_filter_edit() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "symbol.new", json!({"name": "Mark"})); + let in_symbol = |s: &Session| { + let mut xml = vec![]; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| xml.extend(n.appearance.effects.iter().map(|e| e.params["xml"].clone()))); + xml + }; + assert_eq!(in_symbol(&s), [json!(SHIFT)]); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + assert_eq!(in_symbol(&s), [json!(SHIFT_MORE)]); +} + +/// A graphic style that holds a filter follows its edits, so applying it later gives the edited +/// filter. +#[test] +fn a_graphic_style_follows_a_filter_edit() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "graphicStyle.new", json!({"name": "Shifted", "id": a.0})); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT_MORE, "replace": "Shift"})); + run(&mut s, "graphicStyle.apply", json!({"name": "Shifted", "ids": [b.0]})); + assert_eq!(drawn(&s, b), json!(SHIFT_MORE)); +} + +/// An edited filter keeps its name, characters XML escapes included. +#[test] +fn an_edit_keeps_a_name_with_escaped_characters() { + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + assert_eq!(names(&mut s), ["a&b"]); + run(&mut s, "svgFilter.define", json!({"xml": r#""#, "replace": "a&b"})); + assert_eq!(names(&mut s), ["a&b"]); +} + +#[test] +fn effect_apply_takes_a_filter_and_names_it_by_its_id() { + let mut s = session(); + rect(&mut s, 10.0); + let r = run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}})); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["name"], "Shift"); + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter"})).contains("give `xml`")); + for scale in [json!(0), json!(1001), json!("2")] { + let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT, "scale": scale}})); + assert!(e.contains("`scale` must be a number"), "{scale}: {e}"); + } + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": ""}})).contains("give one element")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": ""}})).contains("needs an id")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"scale": 0}})).contains("`scale` must be a number from 0.001")); + assert!(err(&mut s, "effect.setParams", json!({"index": 0, "params": {"name": "Other"}})).contains("give `xml` with `name`")); + // Another filter renames the effect. + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"xml": BLUR}})); + let p = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params; + assert_eq!((p["name"].as_str(), p["xml"].as_str()), (Some("Soft"), Some(BLUR))); +} + +/// An effect names the document's filter, so effect.apply and effect.setParams refuse XML that +/// differs from the document's filter of the same id, and take XML that matches it. +#[test] +fn effect_commands_refuse_another_definition_of_a_document_filter() { + let mut s = session(); + rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": BLUR})); + let other = r#""#; + let before = steps(&s); + let e = err(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": other}})); + assert!(e.contains("another SVG filter named \"Soft\""), "{e}"); + assert_eq!(steps(&s), before, "nothing recorded"); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": format!(" {BLUR} ")}})); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "params": {"xml": SHIFT}})); + let e = err(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": other}})); + assert!(e.contains("another SVG filter named \"Soft\""), "{e}"); + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"xml": BLUR}})); +} + +/// An effect whose filter `check` refuses (put there by an opened file or a script, not by a +/// command) is neither drawn nor written to SVG. +#[test] +fn svg_export_leaves_out_a_filter_that_fails_the_check() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let bad = r#""#; + assert!(err(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": bad}})).contains("