diff --git a/Cargo.lock b/Cargo.lock index eca47581b..6662e1ee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4686,7 +4686,9 @@ dependencies = [ name = "vectorcraft-eps" version = "0.12.0" dependencies = [ + "fax", "flate2", + "hayro-ccitt", "image", "kurbo 0.13.1", "ruzstd", diff --git a/Cargo.toml b/Cargo.toml index 1c10d551b..4198b8e20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,9 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] rustix = { version = "1.1", default-features = false } # Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art. ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } +# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through +# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version. +hayro-ccitt = "0.3" jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG diff --git a/README.md b/README.md index 36fe11fe9..cc7942e0b 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ The scores are self-assessed from our code against Illustrator's public document - reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); -- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery; +- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery; - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); - generative AI features. diff --git a/ROADMAP.md b/ROADMAP.md index a92422e1f..853cd57b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| -| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | | **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | @@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so | Strokes, brushes, width profiles | 88% | 8–15 | | Appearance, transparency, graphic styles, masks | 97% | 1–2 | | Live vector effects | 85% | 6–10 | -| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 | +| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 | | 3D and Materials | ~25% | 30–55 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | | Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap 2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. 3. **Launch and stability on real machines** (G10): 20–35 h. 4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. -5. **Raster effects** (G2) alongside: 28 filters and the Effect Gallery, 23–39 h. +5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h. 6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). ## Progress log @@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). | | 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5). | 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. | | 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). | diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bb3879b47..8d09dcb68 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -159,6 +159,15 @@ fn main() -> ExitCode { } } +/// A headless session that remembers what the user keeps between runs: the PDF presets saved and +/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop +/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there. +fn with_stores(mut h: Headless) -> Headless { + prefs_dir::attach_profile_store(&mut h.session); + prefs_dir::attach_pdf_preset_store(&mut h.session); + h +} + fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut headless = false; @@ -198,14 +207,14 @@ fn mcp(args: &[String]) -> Result<(), String> { eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); Box::new(Headless::with_document().with_automation_roots(Some(roots))) } else if headless { - Box::new(Headless::with_document()) + Box::new(with_stores(Headless::with_document())) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) } else { match Remote::connect(DEFAULT_ADDR) { Ok(r) => Box::new(r), - Err(_) => Box::new(Headless::with_document()), + Err(_) => Box::new(with_stores(Headless::with_document())), } }; eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe()); @@ -213,8 +222,8 @@ fn mcp(args: &[String]) -> Result<(), String> { // uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel. vectorcraft_mcp::logging::install(); let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) + // The server reads and writes from threads of its own, so it takes the handles, not locks held here. + Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) } fn commands() -> Result<(), String> { @@ -327,7 +336,7 @@ fn run(args: &[String]) -> Result<(), String> { } } - let mut h = Headless::new(); + let mut h = with_stores(Headless::new()); let mut out = std::io::stdout().lock(); // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later // steps (an export…) still run, unprinted. diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 37bb28c22..32c5e7f09 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -7,10 +7,18 @@ use serde_json::{Value, json}; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); +/// Returns the path of `name` in `vectorcraft-cli-test-` in the temp folder. The first call removes +/// that folder and creates it again empty. fn tmp(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())); + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name) +} + +/// Removes `dir` and everything in it, creates it again empty and returns it. +fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { + let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir.join(name) + dir } #[test] @@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() { /// server starts, and the process then reads and writes only inside its roots. #[test] fn mcp_automation_roots() { - let base = tmp("roots"); + automation_roots(&tmp("roots")); +} + +/// A run that has the process id of an earlier run finds that run's files and links in its folder. +/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing +/// with "File exists". +#[test] +fn mcp_automation_roots_in_a_folder_an_earlier_run_left() { + let dir = tmp("earlier-run"); + automation_roots(&dir.join("roots")); + automation_roots(&empty_dir(dir).join("roots")); +} + +fn automation_roots(base: &std::path::Path) { for d in ["in", "out", "outside"] { std::fs::create_dir_all(base.join(d)).unwrap(); } @@ -346,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); assert!(!out.status.success()); } + +/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the +/// developer's real profile. +fn config_dir(name: &str) -> std::path::PathBuf { + let dir = tmp(&format!("config-{name}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`; +/// the result of each step. +fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec { + let mut cmd = Command::new(BIN); + cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS"); + for (id, params) in steps { + cmd.args(["--cmd", id, "--params", ¶ms.to_string()]); + } + let out = cmd.output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()["result"].clone()).collect() +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +fn icc_file_named(path: &std::path::Path, name: &str) { + let builtin = "Display P3"; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile"); + bytes[at..at + to.len()].copy_from_slice(&to); + std::fs::write(path, bytes).unwrap(); +} + +fn profile_names(settings: &Value) -> Vec<&str> { + settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect() +} + +/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in +/// the next run, and gone again once deleted. +#[test] +fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() { + let config = config_dir("restart"); + let icc = tmp("studio-p3.icc"); + icc_file_named(&icc, "StudioRGB3"); + let r = run_in( + &config, + &[ + ("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})), + ("color.loadProfile", json!({"path": icc.to_str().unwrap()})), + ], + ); + assert_eq!(r[1]["name"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + let presets: Vec<&str> = + r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(presets, ["Press"]); + assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer"); + assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1])); + // The profile works as a working space, and the file it came from can go. + std::fs::remove_file(&icc).unwrap(); + let steps = [ + ("edit.colorSettings", json!({"rgb": "StudioRGB3"})), + ("pdf.preset.delete", json!({"name": "Press"})), + ("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})), + ("color.removeProfile", json!({"name": "StudioRGB3"})), + ]; + let r = run_in(&config, &steps); + assert_eq!(r[0]["rgb"], "StudioRGB3"); + assert_eq!(r[3]["removed"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); + assert!(!profile_names(&r[1]).contains(&"StudioRGB3")); +} + +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and +/// leave nothing in the settings folder. +#[test] +fn runs_without_preferences_remember_nothing() { + let config = config_dir("noprefs"); + let out = Command::new(BIN) + .args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#]) + .env("VECTORCRAFT_CONFIG_DIR", &config) + .env("VECTORCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(!config.exists(), "nothing written"); + let r = run_in(&config, &[("pdf.preset.list", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); +} diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/fileio.rs index db1174188..19e2b7b72 100644 --- a/apps/vectorcraft-cli/tests/fileio.rs +++ b/apps/vectorcraft-cli/tests/fileio.rs @@ -8,9 +8,16 @@ use serde_json::Value; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); +/// Returns the path of `name` in `vectorcraft-cli-fileio-` in the temp folder. The first call +/// removes that folder and creates it again empty. fn tmp(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + let dir = DIR.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + }); dir.join(name).to_string_lossy().to_string() } diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs new file mode 100644 index 000000000..ebb8ce871 --- /dev/null +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -0,0 +1,212 @@ +//! Linux Wayland: tell winit where the cursor theme is (#2450). +//! +//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor +//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps +//! showing its default one, until the canvas hides the system pointer to paint a tool glyph +//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup +//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the +//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads. +//! +//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does +//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph. +//! +//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before +//! any thread starts and before the window opens. +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// Where a Flatpak sandbox mounts the host's cursor themes (user and system). +const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"]; + +/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`). +#[derive(Debug, Default, PartialEq)] +pub struct DesktopCursor { + pub theme: Option, + pub size: Option, +} + +/// The variables to set, given the environment, the host icon folders that exist and the +/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set +/// stay as they are. +pub fn plan(var: impl Fn(&str) -> Option, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> { + let set = |name: &str| var(name).is_some_and(|v| !v.is_empty()); + if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) { + return Vec::new(); + } + let mut out = Vec::new(); + if !set("XCURSOR_PATH") && !host_icons.is_empty() { + out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":")))); + } + if !set("XCURSOR_THEME") + && let Some(theme) = desktop.theme.filter(|t| !t.is_empty()) + { + out.push(("XCURSOR_THEME", OsString::from(theme))); + } + if !set("XCURSOR_SIZE") + && let Some(size) = desktop.size.filter(|s| (1..=512).contains(s)) + { + out.push(("XCURSOR_SIZE", OsString::from(size.to_string()))); + } + out +} + +/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`). +const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"]; + +/// The themes `index.theme` lists after `Inherits=`. +fn inherits(index: &str) -> Vec { + index + .lines() + .find_map(|l| l.trim().strip_prefix("Inherits=")) + .map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect()) + .unwrap_or_default() +} + +/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does +/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of +/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells +/// whether a file is there. +pub fn theme_usable( + var: impl Fn(&str) -> Option, + home: Option<&Path>, + read: impl Fn(&Path) -> Option, + exists: impl Fn(&Path) -> bool, +) -> bool { + let dirs: Vec = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) { + Some(v) => std::env::split_paths(&v).collect(), + None => DEFAULT_ICON_DIRS + .iter() + .filter_map(|d| match d.strip_prefix("~/") { + Some(rest) => home.map(|h| h.join(rest)), + None => Some(PathBuf::from(d)), + }) + .collect(), + }; + let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into()); + let mut todo = vec![first]; + let mut seen: Vec = Vec::new(); + while let Some(theme) = todo.pop() { + // Bounded: themes inherit in short chains, and a cycle must end. + if seen.contains(&theme) || seen.len() >= 16 { + continue; + } + for dir in &dirs { + let base = dir.join(&theme); + if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) { + return true; + } + if let Some(index) = read(&base.join("index.theme")) { + todo.extend(inherits(&index)); + } + } + seen.push(theme); + } + false +} + +/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never +/// sets a pointer, and the canvas must not hide the system one (#2450). +pub fn theme_missing() -> bool { + let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from); + !theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists()) +} + +/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`. +fn gsettings_value(output: &str) -> &str { + output.trim().trim_matches('\'') +} + +#[cfg(target_os = "linux")] +fn desktop_cursor() -> DesktopCursor { + let get = |key: &str| { + let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }; + DesktopCursor { + theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()), + size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()), + } +} + +/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them +/// unset. Call first thing in `main`, while the process is single-threaded. +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +pub fn apply() { + let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect(); + for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) { + log::debug!("cursor theme: {name}={value:?}"); + // SAFETY: called at the top of `main`, before the app starts any thread. + unsafe { std::env::set_var(name, value) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v)) + } + + fn adwaita() -> DesktopCursor { + DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) } + } + + #[test] + fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() { + let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita()); + assert_eq!( + got, + vec![ + ("XCURSOR_PATH", OsString::from("/run/host/share/icons")), + ("XCURSOR_THEME", OsString::from("Adwaita")), + ("XCURSOR_SIZE", OsString::from("24")), + ] + ); + } + + #[test] + fn settings_the_user_made_and_other_sessions_stay_untouched() { + let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")]; + assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty()); + assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty()); + // A theme-less desktop and an absurd size set nothing. + let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) }; + assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty()); + } + + #[test] + fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() { + let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"]; + // Compared as paths, so the joins match on Windows too, where they use `\`. + let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p); + let read = |p: &Path| { + if p == Path::new("/i/Breeze/index.theme") { + Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()) + } else if p == Path::new("/i/Loop/index.theme") { + Some("Inherits=Loop\n".to_owned()) + } else { + None + } + }; + let with = |theme: &'static str| { + move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme))) + }; + assert!(theme_usable(with("Bibata"), None, read, exists)); + assert!(theme_usable(with("Breeze"), None, read, exists)); + // No such theme, a theme without cursors, an inheritance loop: nothing to load. + assert!(!theme_usable(with("Nope"), None, read, exists)); + assert!(!theme_usable(with("Loop"), None, read, exists)); + // Unset: Xcursor's default folders and the `default` theme. + assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists)); + } + + #[test] + fn gsettings_output_is_unquoted() { + assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita"); + assert_eq!(gsettings_value("24\n"), "24"); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 17bc010a3..87daef553 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -27,6 +27,7 @@ mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu; mod control_server; +mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; mod logging; @@ -525,8 +526,10 @@ fn services(parent: Parent, ui: egui::Context) -> Services { start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), dialogs_are_sheets: cfg!(target_os = "macos"), wayland, + no_cursor_theme: wayland && cursor_theme::theme_missing(), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), + exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), // Native saves stream into the file as they are encoded. @@ -654,6 +657,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli: "; fn main() -> std::process::ExitCode { + // Before any thread starts: the pointer must find a cursor theme on Wayland (#2450). + #[cfg(target_os = "linux")] + cursor_theme::apply(); // Validate roots before logging, font scans, preferences or the window: a missing value // must not clear an environment root and start the control channel without confinement. let mut control = automation_args::ControlArg::default(); @@ -849,8 +855,14 @@ fn main() -> std::process::ExitCode { "VectorCraft", options, Box::new(move |cc| { - let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone())); + 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); + let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); load_prefs(&mut app, saved); + // 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); // Fit the window to its monitor, or put it back where it was (still hidden). if let Some(w) = cc.winit_window() { app.ui.window = Some(window::restore(w, saved_window)); @@ -1031,9 +1043,9 @@ mod tests { .collect() } - /// Finder offers the app for every file File › Open reads (#295, #354) but Photoshop documents - /// (`UNASSOCIATED_EXTS`), takes over no other app's files, and hands them to the app rather than - /// to AppKit's document machinery. + /// Finder offers the app for every file File › Open reads (#295, #354) but those with an + /// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app + /// rather than to AppKit's document machinery. #[test] fn the_macos_bundle_opens_every_readable_format() { let plist = include_str!("../../../packaging/macos/Info.plist.in"); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 4dd258afa..00298342d 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -24,6 +24,31 @@ pub fn install_fonts() { } } +/// The folder `name` next to the settings, for what the user keeps: none in a run without +/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing. +fn store_dir(name: &str) -> Option { + if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { + return None; + } + Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name)) +} + +/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and +/// keep the ones loaded from now on there. Before anything names a profile. +pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("Color Profiles") { + session.attach_profile_store(dir); + } +} + +/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from +/// now on there. After the preferences were restored: the folder is the one place the presets live. +pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("PDF Presets") { + session.attach_pdf_preset_store(dir); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/vectorcraft/src/printing.rs b/apps/vectorcraft/src/printing.rs index 525f8bc31..643e64c69 100644 --- a/apps/vectorcraft/src/printing.rs +++ b/apps/vectorcraft/src/printing.rs @@ -43,11 +43,32 @@ mod system { let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() }; craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string()) } + #[cfg(windows)] + pub(crate) fn setup_command(printer: Option<&str>, default_printer: Option<&str>) -> Command { + let target = printer.map(str::trim).filter(|p| !p.is_empty()).or_else(|| default_printer.map(str::trim).filter(|p| !p.is_empty())); + if let Some(name) = target { + let mut cmd = Command::new("rundll32.exe"); + cmd.args(["printui.dll,PrintUIEntry", "/e", "/n", name]); + cmd + } else { + let mut cmd = Command::new("explorer"); + cmd.arg("ms-settings:printers"); + cmd + } + } pub fn setup(printer: Option<&str>) -> Result<(), String> { #[cfg(windows)] { - let _ = printer; - Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string()) + let list = printers(); + let default = list.iter().find(|p| p.default).or_else(|| list.first()).map(|p| p.name.as_str()); + // Only an installed printer's own name reaches the command line: `printer` can come + // from a command or MCP, and PrintUIEntry reads options from its arguments. + let printer = printer.map(str::trim).filter(|p| list.iter().any(|q| q.name == *p)); + let mut cmd = setup_command(printer, default); + match cmd.spawn() { + Ok(_) => Ok(()), + Err(_) => Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string()), + } } #[cfg(target_os = "macos")] { @@ -68,3 +89,41 @@ mod system { } } } + +#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))] +mod tests { + use super::system::setup_command; + use std::ffi::OsStr; + + #[test] + fn setup_command_uses_printui_for_specified_printer() { + let cmd = setup_command(Some("Office Laser"), None); + assert_eq!(cmd.get_program(), "rundll32.exe"); + let args: Vec<&OsStr> = cmd.get_args().collect(); + assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Office Laser"]); + } + + #[test] + fn setup_command_falls_back_to_default_printer() { + let cmd = setup_command(None, Some("Default Proofer")); + assert_eq!(cmd.get_program(), "rundll32.exe"); + let args: Vec<&OsStr> = cmd.get_args().collect(); + assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]); + } + + #[test] + fn setup_command_trims_whitespace_and_skips_empty() { + let cmd = setup_command(Some(" "), Some("Default Proofer")); + assert_eq!(cmd.get_program(), "rundll32.exe"); + let args: Vec<&OsStr> = cmd.get_args().collect(); + assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]); + } + + #[test] + fn setup_command_falls_back_to_settings_when_no_printer() { + let cmd = setup_command(None, None); + assert_eq!(cmd.get_program(), "explorer"); + let args: Vec<&OsStr> = cmd.get_args().collect(); + assert_eq!(args, ["ms-settings:printers"]); + } +} diff --git a/contributors/people.toml b/contributors/people.toml index b30eb0fd2..b62e005ac 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -120,3 +120,9 @@ sync_github_name = false real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false + +[people.theromis] +sync_github_name = true + +[people.dennismutuku2005] +sync_github_name = true diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index fc1922e13..cea213cd6 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -171,9 +171,25 @@ impl GenericCmyk { xyz_to_lab(self.to_xyz(cmyk)) } - /// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour). - pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] { - xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) }) + /// 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. + pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { + if absolute { + return self.to_xyz(cmyk); + } + let x = self.to_xyz_rel(cmyk); + if !bpc { + return x; + } + let bp = self.black_rel; + [0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0)) + } + + /// Display sRGB, see [`Self::display_xyz`]. + pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { + xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc)) } /// Lab of the darkest printable black (media-relative). @@ -425,11 +441,12 @@ impl GenericCmyk { self.from_lab(srgb_to_lab(rgb), intent, bpc) } - /// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction. - pub fn gamut_error(&self, src: Lab) -> f32 { - let target = self.gamut_map(src, Intent::RelativeColorimetric, false); + /// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays + /// (with `bpc`, the press black showing as black). + pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 { + let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc); let cmyk = self.separate(target); - delta_e2000(self.to_lab_rel(cmyk), target) + delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src) } } diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 40c13bd56..f2a705857 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result bool { + let mut u = USER.write().unwrap_or_else(|e| e.into_inner()); + let before = u.len(); + u.retain(|x| x.name != name); + let removed = u.len() != before; + drop(u); + if removed { + clear_proof_cache(); + PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed); + } + removed +} + +/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`] +/// removes one: conversions through a profile looked up by name, such as those of a +/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same. pub fn profiles_generation() -> u64 { PROFILES_GENERATION.load(Ordering::Relaxed) } @@ -346,10 +362,10 @@ impl Cms { } } - fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] { + fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0)); match space { - CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute), + CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc), CmykSpace::Device => naive_cmyk_to_rgb(cmyk), CmykSpace::Icc(p) => { let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric }; @@ -376,7 +392,7 @@ impl Cms { { return v; } - Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute) + Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc) } /// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts /// directly into the RGB profile: not through display sRGB, which would clip colours a wider @@ -408,6 +424,15 @@ impl Cms { _ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)), } } + /// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in + /// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows + /// as black). Unclipped, for interpolating tables. + pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab { + match &self.cmyk { + CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)), + _ => self.cmyk_to_lab(cmyk), + } + } /// Lab → working CMYK with `intent`. pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] { match &self.cmyk { @@ -429,7 +454,7 @@ impl Cms { /// CIE Lab (D50) of any colour. pub fn lab(&self, c: &Color) -> Lab { match *c { - Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]), + Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]), Color::Lab { l, a, b } => Lab::new(l, a, b), _ => lab::srgb_to_lab(self.display_rgb(c)), } @@ -490,7 +515,7 @@ impl Cms { Color::Rgb { .. } | Color::Lab { .. } => { let src = self.lab(c); match &self.cmyk { - CmykSpace::Generic(g) => g.gamut_error(src), + CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc), CmykSpace::Device => 0.0, CmykSpace::Icc(_) => { let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false); @@ -523,7 +548,7 @@ impl Cms { ProofTarget::Cmyk(name) => match cmyk_space(name) { Ok(sp) => { let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc); - Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper) + Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc) } Err(_) => srgb, }, @@ -534,7 +559,7 @@ impl Cms { pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] { match &proof.target { ProofTarget::Cmyk(name) => match cmyk_space(name) { - Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper), + Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc), Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper), }, _ => self.cmyk_to_srgb(cmyk, proof.simulate_paper), diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index dd9c137a0..99c16edf4 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -245,6 +245,20 @@ 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). +#[test] +fn generic_cmyk_black_round_trips_with_bpc() { + let c = cms(); + let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric); + let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8); + assert_eq!(back, [0, 0, 0], "{cmyk:?}"); + // The paper still shows as white, and without compensation the press black stays a dark grey. + assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]); + let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false); + assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); +} + #[test] fn lab_colours_round_trip_through_the_cms() { let c = cms_no_bpc(); diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 2e5f099c2..4eca85781 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() { assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk); let p = IccProfile::from_bytes(None, &bytes).unwrap(); assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk)); - let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap(); + // The encoded profile converts without black-point compensation, so compare without it. + let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap(); for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] { let want = cms.cmyk_to_srgb(cmyk, false); let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 742dfb749..ff10699be 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -181,6 +181,10 @@ impl Color { _ => {} } let h = s.strip_prefix('#').unwrap_or(s); + // `from_str_radix` accepts a leading `+`, so check the digits first. + if !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok(); match h.len() { 3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)), @@ -383,7 +387,9 @@ impl Paint { } /// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each /// gradient stop) with their link and tint; `f` returns true when it changed them. A colour - /// left without a link gets tint 1. Returns whether anything changed. + /// left without a link gets tint 1. A freeform gradient's points, which it draws from, go + /// through `f` too, as unlinked colours (a link `f` gives them is dropped). Returns whether + /// anything changed. pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool) -> bool { let mut one = |c: &mut Color, link: &mut Option, tint: &mut f32| { let changed = f(c, link, tint); @@ -394,7 +400,12 @@ impl Paint { }; match self { Paint::Solid { color, swatch, tint } => one(color, swatch, tint), - Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch), + Paint::Gradient(g) => { + let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch); + let points = + g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()).fold(false, |ch, p| one(&mut p.color, &mut None, &mut 1.0) | ch); + stops | points + } _ => false, } } @@ -412,6 +423,14 @@ mod tests { assert_eq!(Color::from_hex("zz"), None); } + #[test] + fn hex_rejects_sign_characters() { + for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] { + assert_eq!(Color::from_hex(bad), None, "{bad}"); + } + assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233")); + } + #[test] fn hsb_roundtrip() { for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] { diff --git a/crates/doc/src/cancel.rs b/crates/doc/src/cancel.rs new file mode 100644 index 000000000..bff510707 --- /dev/null +++ b/crates/doc/src/cancel.rs @@ -0,0 +1,142 @@ +//! Cooperative cancellation and progress for long-running work (exports, flattening, baking). +//! +//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`) +//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the +//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where +//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The +//! token is tied to the thread that entered the scope, so concurrent sessions on other threads +//! (and tests running in parallel) never see each other's cancellation, and code outside any +//! scope is never cancelled. +//! +//! Work that stops early leaves nothing behind: callers drop whatever was half built and report +//! the error, so a cancelled command changes nothing. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// A flag one thread raises and another polls. Cloning shares the flag. +#[derive(Clone, Debug, Default)] +pub struct CancelToken(Arc); + +impl CancelToken { + pub fn new() -> Self { + Self::default() + } + + /// Ask for the work holding this token to stop. + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// The error of work that stopped because it was cancelled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl std::fmt::Display for Cancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("cancelled") + } +} + +impl std::error::Error for Cancelled {} + +/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known. +pub type ProgressSink = Rc; + +struct Scope { + token: CancelToken, + sink: Option, +} + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; +} + +/// Puts the previous scope back, also when the work inside unwinds. +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + // `try_with`: during thread teardown the slot may be gone; nothing to restore then. + let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous); + } +} + +/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread. +pub fn scope(token: &CancelToken, sink: Option, f: impl FnOnce() -> T) -> T { + let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink })); + let _restore = Restore(previous); + f() +} + +/// Has the work running on this thread been cancelled? Always false outside a [`scope`]. +pub fn is_cancelled() -> bool { + CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled())) +} + +/// Is this thread running inside a [`scope`], so that a cancel can reach it? +pub fn is_cancellable() -> bool { + CURRENT.with(|c| c.borrow().is_some()) +} + +/// `Err(Cancelled)` when the work running on this thread has been cancelled. +pub fn check() -> Result<(), Cancelled> { + if is_cancelled() { Err(Cancelled) } else { Ok(()) } +} + +/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`] +/// that has a sink. +pub fn progress(done: u64, total: u64, what: &str) { + // The sink runs outside the borrow, so it may itself call back into this module. + let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone())); + if let Some(sink) = sink { + sink(done, total, what); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_work_inside_the_scope_is_cancelled() { + let t = CancelToken::new(); + assert_eq!(check(), Ok(())); + scope(&t, None, || { + assert_eq!(check(), Ok(())); + t.cancel(); + assert_eq!(check(), Err(Cancelled)); + // A nested scope with its own token shadows, then restores. + scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(()))); + assert!(is_cancelled()); + }); + assert!(!is_cancelled(), "the scope ended"); + } + + #[test] + fn progress_reaches_the_sink_and_the_scope_survives_a_panic() { + let seen = Rc::new(RefCell::new(vec![])); + let sink: ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let t = CancelToken::new(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + scope(&t, Some(sink), || { + progress(1, 3, "page"); + panic!("boom"); + }) + })); + assert!(r.is_err()); + progress(2, 3, "after"); + assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]); + } +} diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e4c73f28a..008770bdc 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -2,10 +2,12 @@ //! //! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's //! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK -//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the -//! latter, Deflate-compressed. Every other image decodes to RGB(A) only. +//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink). +//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image +//! decodes to RGB(A) only. use std::io::Cursor; +use std::sync::Arc; use zune_jpeg::zune_core::bytestream::ZCursor; use zune_jpeg::zune_core::colorspace::ColorSpace; @@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool { bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") } -/// The colour type of TIFF `bytes` and its decoder. +/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than +/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane +/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2) +/// has a plane for each channel; other files have one. The decoder multiplies these counts without +/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel +/// wide, claims at least 2^32 samples. +/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images +/// and for bytes that aren't a TIFF. +pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> { + let le = bytes.starts_with(b"II*\0"); + if !le && !bytes.starts_with(b"MM\0*") { + return None; + } + let u16_at = |at: usize| -> Option { + let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }) + }; + let u32_at = |at: usize| -> Option { + let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }) + }; + let ifd = usize::try_from(u32_at(4)?).ok()?; + // (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its + // single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points) + let entry = |i: usize| -> Option<(u16, Option)> { + let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?; + let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?); + let value = match (ty, count) { + (1, 1) => bytes.get(at).map(|&b| u32::from(b)), + (3, 1) => u16_at(at).map(u32::from), + (4 | 13, 1) => u32_at(at), + (16 | 18, 1) => { + let at = usize::try_from(u32_at(at)?).ok()?; + let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?); + let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) }; + u32::try_from(wide).ok() + } + _ => None, + }; + Some((u16_at(e)?, value)) + }; + let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false); + let (mut rows, mut tile_w, mut tile_h) = (None::, None::, None::); + let least = |was: Option, v: u32| Some(was.map_or(v, |w| w.min(v))); + for i in 0..usize::from(u16_at(ifd)?) { + let Some((tag, value)) = entry(i) else { break }; + let Some(v) = value else { continue }; + match tag { + 256 => width = width.max(v), + 257 => height = height.max(v), + 277 => planes = planes.max(v), + 278 => rows = least(rows, v), + 284 => planar |= v == 2, + 322 => tile_w = least(tile_w, v), + 323 => tile_h = least(tile_h, v), + _ => {} + } + } + let planes = if planar { u128::from(planes) } else { 1 }; + let tiles = match (tile_w, tile_h) { + (Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128, + _ => false, + }; + let rows = rows.unwrap_or(height); + let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX); + (tiles || strips).then_some((width, height)) +} + +/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too. fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder>)> { - if !is_tiff(bytes) { + if !is_tiff(bytes) || oversized_tiff(bytes).is_some() { return None; } let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?; Some((d.colortype().ok()?, d)) } +/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? +fn planar(d: &mut tiff::decoder::Decoder>) -> bool { + d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) +} + +/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color +/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file +/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to +/// that many samples, which includes images whose planes together need more than the decoder's +/// limit (256 MB of samples). +fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { + let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); + let layout = d.read_image_to_buffer(&mut buf).ok()?; + let data = match buf { + tiff::decoder::DecodingResult::U8(v) => v, + tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + _ => return None, + }; + if Some(data.len()) != n.checked_mul(per) { + return None; + } + if layout.planes <= 1 { + return Some(data); + } + if layout.planes != per { + return None; + } + let planes: Vec<&[u8]> = data.chunks_exact(n).collect(); + Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect()) +} + impl ImageBlob { /// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written. pub fn cmyk_tiff(inks: &Inks) -> Option { - use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; + Self::cmyk_tiff_at(inks, None) + } + + /// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per + /// inch) when it is given and both are finite and above 0. + pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option { + use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; + use tiff::tags::{ResolutionUnit, Tag}; let mut out = Cursor::new(Vec::new()); - TiffEncoder::new(&mut out) - .ok()? - .with_compression(Compression::Deflate(DeflateLevel::Balanced)) - .write_image::(inks.width, inks.height, &inks.data) - .ok()?; + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + let mut img = enc.new_image::(inks.width, inks.height).ok()?; + if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; + } + img.write_data(&inks.data).ok()?; Some(Self::new("image/tiff", out.into_inner())) } @@ -88,6 +201,53 @@ impl ImageBlob { let bytes = self.bytes.as_slice(); if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) } } + + /// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image + /// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered + /// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images + /// [`Self::cmyk`] doesn't read. The inks of the last CMYK image are kept for the next sample + /// (the Eyedropper samples one image click after click), as [`Self::sample_object`] keeps its + /// pixels. + pub fn sample_cmyk_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> { + thread_local! { + static LAST: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; + } + if !self.is_cmyk() { + return None; + } + let key = self.content_key(); + let inks = LAST.with_borrow_mut(|last| match last { + Some((k, inks)) if *k == key => Some(inks.clone()), + _ => { + let inks = Arc::new(self.cmyk()?); + *last = Some((key, inks.clone())); + Some(inks) + } + })?; + let k = |n: u32, of: u32| n as f64 / of.max(1) as f64; + sample_inks(&inks, x * k(inks.width, object.0), y * k(inks.height, object.1), size) + } +} + +/// [`ImageBlob::sample_cmyk_object`] of `inks` at (`x`, `y`) in their own pixel space. +fn sample_inks(inks: &Inks, x: f64, y: f64, size: u32) -> Option<[u8; 4]> { + let (w, h) = (i64::from(inks.width), i64::from(inks.height)); + let (cx, cy) = (x.floor() as i64, y.floor() as i64); + if !(0..w).contains(&cx) || !(0..h).contains(&cy) { + return None; + } + let r = i64::from(size.max(1)) / 2; + let (mut sum, mut n) = ([0u64; 4], 0u64); + for py in (cy - r).max(0)..=(cy + r).min(h - 1) { + for px in (cx - r).max(0)..=(cx + r).min(w - 1) { + let i = usize::try_from((py * w + px) * 4).ok()?; + let p = inks.data.get(i..i + 4)?; + sum.iter_mut().zip(p).for_each(|(s, v)| *s += u64::from(*v)); + n += 1; + } + } + let c = |v: u64| ((v + n / 2) / n) as u8; + Some(sum.map(c)) } /// The ink amounts of a four-component JPEG (its samples inverted back; YCCK converted to CMYK @@ -119,34 +279,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option { Inks::new(w, h, data) } -/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits). +/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane +/// per ink. fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - // Planar samples (one plane per ink) would read as interleaved. - let planar = d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); - if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() { + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { return None; } - let data = match d.read_image().ok()? { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; - Inks::new(w, h, data) + let n = usize::try_from(pixels(w, h)?).ok()?; + Inks::new(w, h, read_samples(&mut d, n, 4)?) +} + +/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per +/// ink, which the image decoder doesn't read, their ink amounts written again by +/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK +/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { + let inks = tiff_inks(bytes)?; + let (_, mut d) = tiff(bytes)?; + if !planar(&mut d) { + return Some(bytes.to_vec()); + } + ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), /// and grayscale with extra samples, of which the first is the alpha when the file says so (other -/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other -/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. +/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be +/// stored together or one plane per channel. `None` for other images and ones larger than +/// [`MAX_CMYK_PIXELS`]. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { use tiff::tags::Tag; let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - let planar = d.find_tag_unsigned::(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); // 0: white is zero, 1: black is zero (the two grayscales). let photometric = d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok().flatten(); // ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha. @@ -159,18 +327,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) } _ => return None, }; - if planar { - return None; - } let n = usize::try_from(pixels(w, h)?).ok()?; - let data: Vec = match d.read_image().ok()? { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; - if Some(data.len()) != n.checked_mul(samples) { - return None; - } + let data = read_samples(&mut d, n, samples)?; let mut out = Vec::with_capacity(n * 4); for p in data.chunks_exact(samples) { let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX }; @@ -246,6 +404,64 @@ mod tests { assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque"); } + /// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel, + /// times 2 planes. + #[test] + fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() { + let header = |planar: u32| { + let mut f = b"II*\0\x08\0\0\0".to_vec(); + let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)]; + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + for (tag, v) in entries { + f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat()); + f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat()); + } + f.extend([0; 4]); + f + }; + assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX))); + assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits"); + assert!(tiff(&header(2)).is_none(), "not decoded"); + } + + /// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2 + /// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and + /// reads a LONG8 value from where the entry points. + #[test] + fn tiffs_with_more_strips_than_can_be_counted_are_oversized() { + // Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory). + let header = |entries: &[(u16, u16, u32)]| { + let after = 8 + 2 + 12 * entries.len() + 4; + let mut f = b"II*\0\x08\0\0\0".to_vec(); + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + let mut wide = vec![]; + for &(tag, ty, v) in entries { + f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat()); + f.extend(1u32.to_le_bytes()); + if ty == 16 { + f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes()); + wide.extend(u64::from(v).to_le_bytes()); + } else { + f.extend(v.to_le_bytes()); + } + } + f.extend([0; 4]); + f.extend(wide); + f + }; + let tall = 1u32 << 31 | 1; + let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)]; + assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall))); + let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat(); + assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets"); + let mut long8 = strips; + long8[1] = (257, 16, tall); + assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8"); + for h in [header(&tile_sizes), header(&long8)] { + assert!(tiff(&h).is_none(), "not decoded"); + } + } + fn inks() -> Inks { Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap() } @@ -261,6 +477,20 @@ mod tests { assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]); } + #[test] + fn a_cmyk_image_samples_its_ink_amounts() { + let b = ImageBlob::cmyk_tiff(&inks()).unwrap(); + assert_eq!(b.sample_cmyk_object(0.5, 0.5, (3, 2), 1), Some([0, 40, 100, 0])); + // An object of 6 × 4 pixels showing these 3 × 2: (5, 3) is the last pixel. + assert_eq!(b.sample_cmyk_object(5.0, 3.0, (6, 4), 1), Some([12, 34, 56, 78])); + // 3 × 3 around (1, 0), clipped to the image: all six pixels averaged. + assert_eq!(b.sample_cmyk_object(1.5, 0.5, (3, 2), 3), Some([45, 55, 69, 56])); + assert_eq!(b.sample_cmyk_object(3.5, 0.5, (3, 2), 1), None, "outside"); + let mut png = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert_eq!(ImageBlob::png(png).sample_cmyk_object(0.5, 0.5, (2, 2), 1), None, "an RGB image has no inks"); + } + #[test] fn a_cmyk_jpeg_reads_back_as_ink_amounts() { // A flat CMYK JPEG: Adobe's inverted samples with its marker. diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index 986442b29..ccf884fda 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use std::collections::BTreeSet; -use vectorcraft_geom::corners::{Corner, cut_corners, path_corners}; +use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners}; use vectorcraft_geom::shapes::{self, CornerKind}; use vectorcraft_geom::{Affine, PathData, Rect}; @@ -23,6 +23,8 @@ pub struct LiveCorners<'a> { /// The corners that can be cut, in anchor order ([`path_corners`] of `base`). pub corners: Vec, radii: Vec, + /// The radius each of `corners` is drawn with ([`fitted_radii`]). + drawn: Vec, kinds: Vec, /// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from. sources: Vec>, @@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> { }) .collect() }; - Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources }) + let corners = path_corners(&base); + let drawn = fitted_radii(&corners, &radii); + Some(Self { corners, base, xf, radii, drawn, kinds, sources }) } /// The Live Corners of a path object. @@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> { self.radii.get(index).copied().unwrap_or(0.0) } + /// The radius corner `index` is drawn with, in document units: its own, no larger than its + /// neighbours leave room for (0 for an anchor that is no corner). + pub fn drawn_radius(&self, index: usize) -> f64 { + self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0) + } /// The kind corner `index` is cut with. pub fn kind(&self, index: usize) -> CornerKind { self.kinds.get(index).copied().unwrap_or_default() diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 37329ec62..9da5d0d0c 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -196,6 +196,57 @@ macro_rules! ids { ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" }); ids!(PieSort { None => "none", All => "all", First => "first" }); +/// Object › Graph › Column › Column Type: how a column design fills a column or bar. Vertically Scaled stretches it +/// along the value axis to the column's length at the column's width; Uniformly Scaled scales it both ways to that +/// length, centered on the column; Repeating stacks copies that each stand for [`ColumnDesign::each`]; Sliding scales the +/// part above its guide line (in a bar, right of it) in proportion to the column's width and stretches the rest. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColumnType { + #[default] + Vertical, + Uniform, + Repeating, + Sliding, +} + +/// Object › Graph › Column › For Fractions: a repeating design's last copy, for the part of a value less than one +/// design, is cut off (Chop) or scaled down along the value axis (Scale). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Fractions { + #[default] + Chop, + Scale, +} + +ids!(ColumnType { Vertical => "vertical", Uniform => "uniform", Repeating => "repeating", Sliding => "sliding" }); +ids!(Fractions { Chop => "chop", Scale => "scale" }); + +/// Object › Graph › Column: the graph design a series' columns or bars are drawn with, and how it fills them. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ColumnDesign { + pub design: String, + #[serde(skip_serializing_if = "ColumnType::is_default")] + pub column_type: ColumnType, + /// Repeating: the value one copy of the design stands for (Each Design Represents). + pub each: f64, + #[serde(skip_serializing_if = "Fractions::is_default")] + pub fractions: Fractions, +} + +impl ColumnDesign { + /// Each Design Represents for a new column design. + pub const EACH: f64 = 10.0; +} + +impl Default for ColumnDesign { + fn default() -> Self { + Self { design: String::new(), column_type: ColumnType::Vertical, each: Self::EACH, fractions: Fractions::Chop } + } +} + /// Graph Type › Value Axis: which side of the plot the value axis is drawn on. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -345,6 +396,11 @@ pub struct GraphSpec { /// series index, for line, scatter and radar series; `None` and missing entries draw the default square. #[serde(skip_serializing_if = "Vec::is_empty")] pub series_markers: Vec>, + /// Object › Graph › Column: the graph design each series' columns or bars (and legend swatch) are drawn with, by + /// series index, for column, stacked column, bar and stacked bar series; `None` and missing entries draw plain + /// columns. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub series_columns: Vec>, /// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one: /// length, and tick marks per division (0 and 1 = one, at the labels). #[serde(skip_serializing_if = "TickLength::is_short")] @@ -430,17 +486,22 @@ impl GraphSpec { let cells = self.cells(); let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len()); let mut out = String::from(","); - out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::>().join(",")); + // Quote a label holding a separator, quote or line break (inner quotes doubled), or one with + // surrounding spaces; `always` also quotes blank and numeric labels so they stay labels. + let field = |c: &str, always: bool| { + let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim(); + if special || (always && (c.trim().is_empty() || c.trim().parse::().is_ok())) { + format!("\"{}\"", c.replace('"', "\"\"")) + } else { + c.to_string() + } + }; + out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::>().join(",")); out.push('\n'); let labelled = !self.categories.is_empty(); for (i, r) in cells.iter().enumerate() { if labelled { - let c = self.categories.get(i).map(String::as_str).unwrap_or_default(); - if c.trim().is_empty() || c.trim().parse::().is_ok() || c != c.trim() { - out.push_str(&format!("\"{c}\"")); - } else { - out.push_str(c); - } + out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true)); out.push(','); } out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::>().join(",")); @@ -474,6 +535,7 @@ impl Default for GraphSpec { right_axis_min: None, right_axis_max: None, series_markers: vec![], + series_columns: vec![], label_styles: LabelStyles::default(), first_row_in_front: false, first_column_in_front: false, diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index bf84b450a..bcbe731b6 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -294,11 +294,17 @@ fn edits_contents(n: &Node) -> bool { /// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame: /// glyph outlines need the font engine, above this crate. fn clip_contains(clip: &Node, p: Point) -> bool { + clip_regions(clip).iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) +} + +/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in +/// [`clip_contains`]. +fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> { let frame = |n: &Node| { let b = n.geometric_bounds()?; Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default())) }; - clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) + clip.clip_shapes(Some(&frame)) } /// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is @@ -464,15 +470,39 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option } } +/// 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`. +pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { + let NodeKind::Compound { children, rule } = &n.kind else { return None }; + let parts: Vec<_> = children.iter().rev().filter_map(|c| c.path_data().map(|pd| (c.id, pd.to_bezpath(), pd.bounds()))).collect(); + if let Some((id, ..)) = parts.iter().find(|(_, bp, _)| stroke_contains(bp, 0.0, tol, p)) { + return Some(*id); + } + // The innermost by the size of its box. + let size = |b: &Option| b.map_or(f64::INFINITY, |b| b.area()); + parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id) +} + /// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol -/// instances are measured by their art ([`Node::geometric_bounds_with`]). +/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group +/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping +/// path clips to, and then only within that region's bounds. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { let mut out = Vec::new(); - let tops: Vec<&std::sync::Arc> = match scope.and_then(|s| doc.node(s)) { - Some(s) => match s.children() { - Some(c) => c.iter().collect(), + // `tops` pairs each object with the part of `r` in which the marquee can touch it. + let tops: Vec<(&std::sync::Arc, Rect)> = match scope.and_then(|s| doc.node(s)) { + Some(s) => match (s.children(), marquee_in(s, r)) { + (Some(c), Some(r)) => c.iter().map(|c| (c, r)).collect(), + (Some(_), None) => vec![], // A single object isolated: only it. - None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(), + (None, _) => doc + .parent_of(s.id) + .and_then(|p| doc.node(p)) + .and_then(|p| Some((p.children()?, marquee_in(p, r)?))) + .into_iter() + .flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r))) + .collect(), }, // The objects of the layers, looking through sublayers (hidden, locked and template ones // are left out). @@ -480,18 +510,21 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> .layers .iter() .filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id)) - .flat_map(|l| layer_objects(l)) + .flat_map(|l| layer_objects(l, r)) .collect(), }; - fn layer_objects(l: &Node) -> Vec<&std::sync::Arc> { + /// The objects of layer `l`, each with the part of `r` that the clipping masks of `l` and of + /// the sublayers it is in leave. + fn layer_objects(l: &Node, r: Rect) -> Vec<(&std::sync::Arc, Rect)> { let mut out = vec![]; + let Some(r) = marquee_in(l, r) else { return out }; for c in l.children().into_iter().flatten() { if c.is_layer() { if c.visible && !c.locked && !c.is_template() { - out.extend(layer_objects(c)); + out.extend(layer_objects(c, r)); } } else { - out.push(c); + out.push((c, r)); } } out @@ -499,9 +532,10 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool { match &n.kind { NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { - children.iter().any(|c| c.visible && touches(c, s, r)) + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + marquee_in(n, r).is_some_and(|r| children.iter().any(|c| c.visible && touches(c, s, r))) } + NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r)), // A box with no width or no height (an instance of a symbol that draws a straight // line) is touched where the marquee crosses it. _ => n.geometric_bounds_with(s).is_some_and(|b| { @@ -513,6 +547,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + let Some(r) = marquee_in(n, r) else { return }; for c in children { if c.visible && !c.locked { collect_leaves(c, s, r, out); @@ -527,7 +562,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> } } let symbols = doc.symbol_boxes(); - for t in tops { + for (t, r) in tops { if !t.visible || t.locked { continue; } @@ -540,6 +575,36 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> out } +/// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a +/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None +/// when the marquee misses it); otherwise it is all of `r`. +fn marquee_in(parent: &Node, r: Rect) -> Option { + match parent.children().and_then(|c| c.first()) { + Some(clip) if parent.clips() => marquee_in_clip(clip, r), + _ => Some(r), + } +} + +/// The part of a marquee `r` in which it can touch the art that `clip` clips (the clipping path of +/// a clip group or of a layer with a clipping mask): `r` cut to the bounds of the regions `clip` +/// clips to ([`clip_regions`]), or `None` when `r` meets none of those regions. Within those +/// bounds, art outside a region that is not a rectangle still counts. +fn marquee_in_clip(clip: &Node, r: Rect) -> Option { + use vectorcraft_geom::kurbo::Shape; + let regions = clip_regions(clip); + // The edge of a region is its outline with each open subpath closed, as `fill_contains` fills it. + let meets = |(bp, rule): &(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)| { + let mut edge = vectorcraft_geom::PathData::from_bezpath(bp); + edge.subpaths.iter_mut().for_each(|sp| sp.closed = true); + fill_contains(bp, *rule, r.center()) || vectorcraft_geom::hit::intersects_rect(&edge, r) + }; + if !regions.iter().any(meets) { + return None; + } + let bounds = regions.iter().map(|(bp, _)| bp.bounding_box()).reduce(|a, b| a.union(b))?; + Some(r.intersect(bounds)) +} + #[cfg(test)] mod tests { use super::*; @@ -613,6 +678,99 @@ mod tests { assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty()); } + /// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and + /// the art, in that order. + fn half_clipped(d: &mut Document) -> (Node, Node) { + let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art()); + (mask, art) + } + + /// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away + /// half alone it selects nothing, and around a rectangle drawn beside the group's mask it + /// takes that rectangle alone; reaching into the mask takes the group. The same holds for + /// Group Selection's leaves and in isolation mode. + #[test] + fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let (mask, art) = half_clipped(&mut d); + let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id()); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); + // This marquee crosses only the hidden art's top and right edges. + let hidden = Rect::new(150.0, -10.0, 210.0, 50.0); + assert!(marquee(&d, hidden, None, false).is_empty()); + assert!(marquee(&d, hidden, None, true).is_empty()); + assert!(marquee(&d, hidden, Some(g), false).is_empty()); + assert!(marquee(&d, hidden, Some(art_id), false).is_empty()); + // This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge. + assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]); + // This marquee crosses the shown half's top edge and goes on into the hidden half. + let shown = Rect::new(50.0, -10.0, 160.0, 10.0); + assert_eq!(marquee(&d, shown, None, false), vec![g]); + assert!(marquee(&d, shown, None, true).contains(&art_id)); + assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art"); + } + + /// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the + /// clipped art's corner leaves the clip group. An open mask clips to its area closed by a + /// straight line, and a marquee across that line takes the art it reaches inside. + #[test] + fn a_marquee_must_meet_the_clipping_path_itself() { + // A clip group of a mask through `pts` (closed or open) over the art `art`. + let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let mut mask = vectorcraft_geom::BezPath::new(); + mask.move_to(pts[0]); + for &p in &pts[1..] { + mask.line_to(p); + } + if closed { + mask.close_path(); + } + let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default()); + let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art()); + let g = d.alloc_id(); + let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + d.insert(Some(l), 0, clip).unwrap(); + (d, g) + }; + let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty()); + assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point"); + // The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it. + let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0)); + assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]); + } + + /// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the + /// mask shows them, as in a clip group. + #[test] + fn a_marquee_leaves_art_a_layer_clips_away() { + for sublayer in [false, true] { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let beside = d.alloc_id(); + d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap(); + let masked = d.alloc_id(); + let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2)); + layer.set_clips(true); + d.insert(sublayer.then_some(l), 9, layer).unwrap(); + let (mask, art) = half_clipped(&mut d); + let (mask_id, art_id) = (mask.id, art.id); + d.insert(Some(masked), 0, mask).unwrap(); + d.insert(Some(masked), 1, art).unwrap(); + assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}"); + assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}"); + assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}"); + } + } + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test] diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a11f04725..6ad1973c9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ pub mod appearance; pub mod assets; pub mod blend; +pub mod cancel; pub mod clipnest; pub mod cmyk; pub mod corners; @@ -21,13 +22,16 @@ mod inline; pub mod intertwine; pub mod shared; mod symbol_export_type; +mod symbol_nine_slice; mod symbol_registration; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; +pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice}; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; pub mod live; +pub mod live_paint; pub mod marks; mod mem; pub mod metadata; @@ -89,17 +93,18 @@ pub use assets::ExportAsset; pub use corners::LiveCorners; pub use depth::MAX_DEPTH; pub use graph::{ - GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength, - ValueAxisSide, + ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, + PieSort, SeriesPaint, TickLength, ValueAxisSide, }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; +pub use live_paint::{GapOptions, GapSize}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask}; -pub use node::{ImageMap, ObjectAttributes}; +pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; @@ -591,6 +596,23 @@ pub struct ImageBlob { pub proxy: Option>>, } +/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a +/// new document, or one origin for the whole document. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RulerMode { + #[default] + Artboard, + Global, +} + +impl RulerMode { + /// The mode of a document saved before the choice existed: global, as its rulers measured. + pub fn legacy() -> Self { + Self::Global + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { pub version: u32, @@ -604,6 +626,15 @@ pub struct Document { /// The rulers' zero point in document coordinates. Moving it changes measurements, never art. #[serde(default, skip_serializing_if = "skip::is_default")] pub ruler_origin: Point, + /// View › Rulers › Change to Global / Artboard Rulers: whether the rulers and position fields + /// measure from [`Self::ruler_origin`] (global) or from the active artboard's own origin. + /// Documents saved before the choice existed measured globally and open that way. + #[serde(default = "RulerMode::legacy")] + pub rulers: RulerMode, + /// Artboard rulers' zero point of each artboard (by [`Artboard::id`]), as an offset from its + /// top-left corner; an artboard not listed measures from that corner. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub artboard_ruler_origins: BTreeMap, #[serde(default)] pub color_mode: ColorMode, pub artboards: Vec, @@ -694,6 +725,10 @@ pub struct Document { /// save time only, so changing the view never marks the document modified. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_view: Option, + /// View → Show Transparency Grid when the document was saved. Written at save time only, + /// like [`Document::last_view`]; older files open with the grid hidden. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub transparency_grid: bool, /// The layers, sublayers and groups open in the Layers panel when the document was saved; they /// reopen that way. `None`: the default, only the top-level layers open. Written at save time /// only, like [`Document::last_view`]. @@ -726,6 +761,10 @@ pub struct Document { /// export settings are Export for Screens' ([`Document::export_settings`]). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub assets: Vec, + /// Window › SVG Interactivity › Link JavaScript Files: the JavaScript files (paths or URLs, as + /// given) SVG export links with ` + + "#); + let (doc, warnings) = import_with(&text, &ImportOptions { folder: Some("/tmp"), read: Some(&read) }).unwrap(); + let asked = asked.lock().unwrap().clone(); + assert!(asked.iter().any(|p| p.contains("photo.png")) && !asked.iter().any(|p| p.contains("evil")), "read {asked:?}"); + assert_eq!(doc.svg_scripts, ["evil.js"], "each link once, the inline script left out"); + assert!(warnings.iter().any(|w| w.contains("inline scripts were left out")), "{warnings:?}"); + assert_eq!(events(&doc, "shape"), [pair("onclick", "fetch('https://example.com')")]); + assert_eq!(doc.title, "Untitled-1", "the inline script didn't run"); +} +/// Import keeps the first 64 different `