diff --git a/Cargo.lock b/Cargo.lock index 0c34a6a952..dc08d4542e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6341,7 +6341,6 @@ dependencies = [ "raw-window-handle", "rfd", "serde_json", - "smithay-clipboard", "socket2", "tempfile", "vectorcraft-engine", @@ -6666,6 +6665,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "siphasher", "skrifa 0.47.0", "smallvec", "thiserror 2.0.21", diff --git a/ROADMAP.md b/ROADMAP.md index b5a2f43c7a..fe325ff849 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. +- 2026-11-13: Gradient panel › Method: Hue Clockwise and Hue Counterclockwise mix a linear, radial, conic or diamond gradient's colors around the hue wheel in one direction, so red to blue passes through magenta one way and through yellow and green the other (`paint.editGradient` `method`: `hue-clockwise`, `hue-counterclockwise`); SVG and PDF exports write such a gradient as an image. - 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 0d4deca36f..b66c15070f 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -418,10 +418,18 @@ fn convert(args: &[String]) -> Result<(), String> { // Save as Template writes every artboard, with the .ai options. ("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy})) } else { - // A conversion keeps hidden layers and objects, written hidden, where the format can (SVG, - // PSD). - let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy}); + let params = json!({"path": output, "artboard": artboard, "range": range, "acknowledgeLoss": replace_lossy}); let mut params = with_pixel_scale(params, &output, scale, ppi); + // An option the format doesn't read comes back in the warnings, so each goes out only + // when it was asked for: `outlineText` with --outline-text, and `hiddenLayers` to the + // formats that have it (SVG, PSD), where a conversion keeps hidden layers and objects, + // written hidden. + if outline_text { + params["outlineText"] = json!(true); + } + if vectorcraft_engine::cmd::fileio::writable_format(None, Some(&output)).is_ok_and(|f| f.options.iter().any(|o| o.name == "hiddenLayers")) { + params["hiddenLayers"] = json!(true); + } if let Some(decimals) = decimals { params["decimals"] = json!(decimals); } diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs index 71044be4a5..cdb16d28d3 100644 --- a/apps/vectorcraft-cli/tests/it/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -177,6 +177,27 @@ fn convert_reports_import_warnings() { assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}"); } +/// A conversion sends each format only the options it reads, so its warnings name an unused +/// option only when the command line gave one: `--outline-text` for a format without it. +#[test] +fn convert_warns_about_an_unused_option_only_when_one_was_given() { + let svg = tmp("unused-option.svg"); + std::fs::write(&svg, r#""#).unwrap(); + let unused = |out: &Value| -> Vec { + let w = out["warnings"].as_array().unwrap_or_else(|| panic!("{out}")); + w.iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect() + }; + for ext in ["png", "jpg", "pdf", "ai", "eps", "psd", "dxf", "svg", "txt", "vectorcraft", "vctemplate"] { + let path = tmp(&format!("unused-option.{ext}")); + let out: Value = serde_json::from_str(ok(&["convert", &svg, &path]).trim()).unwrap(); + assert!(unused(&out).is_empty(), "{ext}: {out}"); + } + let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.svg"), "--outline-text"]).trim()).unwrap(); + assert!(unused(&out).is_empty(), "{out}"); + let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.png"), "--outline-text"]).trim()).unwrap(); + assert_eq!(unused(&out), ["`outlineText` was not used: PNG files don't read it"], "{out}"); +} + #[test] fn convert_writes_legacy_ai_with_its_options() { // Two artboards, a shown rectangle and a hidden one. diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 55b9e344e7..2da5f0083d 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -58,7 +58,10 @@ pollster = "1" # cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which # src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`, # which src/host_services.rs uses to read the path PhotoCraft registers under App Paths. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# Win32_Devices_Display has `QueryDisplayConfig`, Win32_UI_ColorSystem the display's ICC profile and +# Win32_System_LibraryLoader `GetProcAddress`, which src/display_profile.rs uses to read the +# display's color profile for the canvas. +windows = { version = "0.62", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_ColorSystem", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } # The renderer's worker count follows those cores (src/windows_cores.rs). vectorcraft-render = { workspace = true } @@ -93,29 +96,30 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a # A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a # second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build # through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs). -x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } +# RandR reads a display's `_ICC_PROFILE` for the canvas (src/display_profile.rs). +x11rb = { version = "0.13", default-features = false, features = ["randr", "xinput", "xkb"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), -# so Automatic draws on that one. Both are already in the build through winit. +# so Automatic draws on that one, and copies and pastes text, SVG and PNG through the compositor's +# core data-device protocol on the window's connection (src/clipboard.rs), where arboard has neither +# data-control (GNOME) nor X11 (a Flatpak on a Wayland session). Both are already in the build +# through winit. wayland-client = "0.31" -# Copy and Paste of text through the compositor's core data-device protocol on the window's -# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak -# on a Wayland session). MIT, already in the build through egui-winit. -smithay-clipboard = "0.7" wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in # wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build # through winit and rfd. wayland-backend = { version = "0.3", features = ["client_system"] } -# Waits for the screen color sampler's portal answer and for the portal leaving the bus at the same -# time (src/linux_screen_color/request.rs). Apache-2.0 OR MIT, already in the build through zbus. -futures-lite = "2" # The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the # systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned # exactly: review the diff before bumping. [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies] zbus = "=5.19.0" +# Waits for a portal answer (the save dialog's and the screen color sampler's) and for the portal +# leaving the bus at the same time (src/portal_request.rs). Apache-2.0 OR MIT, already in the build +# through zbus. +futures-lite = "2" # Tells whether arboard reads through the compositor's data-control protocol or through X11, the # same check arboard makes (src/clipboard.rs). MIT OR Apache-2.0, already in the build through # arboard. diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 4827772444..126b671877 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -219,6 +219,9 @@ impl App { self.app.services.dock_monitors = window::dock_monitors(w, zoom); self.monitors_listed = Some((now, zoom)); } + if let Some(w) = &self.winit_window { + self.display_profile.tick(w, now, self.app.session.prefs.monitor_profile_display, &self.app.display_profile, ctx); + } self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window { diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 1daccc22f1..77a96442c4 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -10,8 +10,9 @@ //! Linux arboard reads no other formats). On Wayland, arboard's data-control //! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps //! copied), falling back to X11 where the compositor lacks the protocol, and where there is no -//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection, -//! text only. A clipboard that can't be opened is tried again after a wait that grows to a +//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection: +//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads SVG, +//! text and PNG. A clipboard that can't be opened is tried again after a wait that grows to a //! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app //! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at //! once; a paste then waits for that read's answer off the UI thread. @@ -24,8 +25,8 @@ use std::path::PathBuf; use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour}; use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard}; -/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where -/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11). +/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text, SVG and +/// PNG where arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11). pub fn system_clipboard(window: Option>) -> Box { #[cfg(windows)] let cb = { @@ -629,6 +630,10 @@ mod portable { enum Ours { Text(String), Image(usize, usize), + /// A Copy through the Wayland core protocol, whose data source tells whether the + /// clipboard still holds it. + #[cfg(target_os = "linux")] + Source, } /// A read of arboard's clipboard. @@ -858,18 +863,14 @@ mod portable { #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] impl Late { /// What a paste of one of `mimes` takes once the answer comes ([`Unanswered::paste`]). The - /// core protocol's text is waited for as long, until [`IMAGE_WAIT`] after its read was - /// sent, then read again, and pasted unless it is empty, what `ours` put on the clipboard, - /// or doesn't come. + /// core protocol's answer is waited for as long, until [`IMAGE_WAIT`] after its read was + /// sent; then the clipboard is read again and pasted as at once, unless it holds the Copy + /// made here ([`super::wayland::Unanswered::paste`]). fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option { match self { Self::Arboard(unanswered) => unanswered.paste(ours, mimes), #[cfg(target_os = "linux")] - Self::Wayland(unanswered) => { - let text = unanswered.text(IMAGE_WAIT).ok().filter(|t| !t.is_empty())?; - let held = matches!(ours, Some(Ours::Text(t)) if *t == text); - (!held).then(|| Flavour { mime: TEXT, data: text.into_bytes() }) - } + Self::Wayland(unanswered) => unanswered.paste(mimes, IMAGE_WAIT), } } } @@ -962,14 +963,15 @@ mod portable { /// An open clipboard connection. enum Backend { Arboard(Arboard), - /// The compositor's core data-device protocol on the window's connection (text only). + /// The compositor's core data-device protocol on the window's connection. #[cfg(target_os = "linux")] - Wayland(super::wayland::TextClipboard), + Wayland(super::wayland::DataClipboard), } - /// What a write of a picture says where the clipboard carries text only. + /// What a bitmap read or write says on the core protocol, which carries pictures as PNG data + /// only ([`Clipboard::write`], [`Clipboard::read_now_or_later`]). #[cfg(target_os = "linux")] - const TEXT_ONLY: &str = "this clipboard carries text only"; + const PNG_ONLY: &str = "this clipboard carries pictures as PNG data"; impl Backend { /// arboard (Wayland data-control, else X11), else the core protocol on `window`'s @@ -979,7 +981,10 @@ mod portable { || Arboard::open().map(Self::Arboard), || { #[cfg(target_os = "linux")] - return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window"); + return window + .and_then(|w| super::wayland::DataClipboard::new(w.clone())) + .map(Self::Wayland) + .ok_or("not a Wayland window with a seat and a data-device manager"); #[cfg(not(target_os = "linux"))] { let _ = window; @@ -993,7 +998,7 @@ mod portable { match self { Self::Arboard(_) => "arboard", #[cfg(target_os = "linux")] - Self::Wayland(_) => "the Wayland core protocol (text only)", + Self::Wayland(_) => "the Wayland core protocol", } } @@ -1001,7 +1006,7 @@ mod portable { match self { Self::Arboard(cb) => cb.read(Request::Text).text(), #[cfg(target_os = "linux")] - Self::Wayland(cb) => cb.get_text(), + Self::Wayland(cb) => cb.text(), } } @@ -1009,10 +1014,17 @@ mod portable { match self { Self::Arboard(cb) => cb.cb.set_text(text).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] - Self::Wayland(cb) => { - cb.set_text(text); - Ok(()) - } + Self::Wayland(cb) => cb.copy(&[Flavour { mime: TEXT, data: text.as_bytes().to_vec() }]), + } + } + + /// Whether the clipboard holds the core protocol's last Copy made here (never for arboard, + /// whose writes are checked by what they wrote). + #[cfg(target_os = "linux")] + fn holds_source(&mut self) -> bool { + match self { + Self::Arboard(_) => false, + Self::Wayland(cb) => cb.holds_ours(), } } @@ -1025,7 +1037,7 @@ mod portable { #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] Self::Arboard(cb) => cb.read(Request::Image).image(), #[cfg(target_os = "linux")] - Self::Wayland(_) => Err(TEXT_ONLY.into()), + Self::Wayland(_) => Err(PNG_ONLY.into()), } } @@ -1041,7 +1053,7 @@ mod portable { Ok(Box::new(move || Ok(image))) } #[cfg(target_os = "linux")] - Self::Wayland(_) => Err(TEXT_ONLY.into()), + Self::Wayland(_) => Err(PNG_ONLY.into()), } } @@ -1049,7 +1061,7 @@ mod portable { match self { Self::Arboard(cb) => cb.cb.set_image(img).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] - Self::Wayland(_) => Err(TEXT_ONLY.into()), + Self::Wayland(_) => Err(PNG_ONLY.into()), } } @@ -1057,15 +1069,12 @@ mod portable { match self { Self::Arboard(cb) => cb.cb.clear().map_err(|e| e.to_string()), #[cfg(target_os = "linux")] - Self::Wayland(cb) => { - cb.set_text(""); - Ok(()) - } + Self::Wayland(cb) => cb.copy(&[]), } } /// The read that got no answer in its wait, when a paste of one of `mimes` can take its - /// answer ([`Arboard::unanswered`], [`TextReads::unanswered`](super::wayland::TextReads::unanswered)). + /// answer ([`Arboard::unanswered`], [`Reads::unanswered`](super::wayland::Reads::unanswered)). #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn unanswered(&mut self, mimes: &[&'static str]) -> Option { match self { @@ -1223,9 +1232,15 @@ mod portable { Some(ClipboardRead::Later(Box::new(move || unanswered.paste(ours.as_ref(), &mimes)))) } - /// Text and copied files are read here, and work that reads arboard's image comes back. + /// Text and copied files are read here, and work that reads arboard's image comes back. On + /// the Wayland core protocol the first of `mimes` the clipboard offers is read: text here, + /// SVG and PNG by the work that comes back, which waits [`IMAGE_WAIT`] at most. fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option { let cb = self.cb(Asker::User).ok()?; + #[cfg(target_os = "linux")] + if let Backend::Wayland(cb) = cb { + return cb.read(mimes, IMAGE_WAIT); + } if let Some(f) = copied_file(cb.file_list(), mimes) { return Some(ClipboardRead::Now(f)); } @@ -1247,13 +1262,23 @@ mod portable { } impl SystemClipboard for Clipboard { + /// arboard takes the text, else the PNG as a bitmap; the Wayland core protocol offers the + /// SVG, PNG and text together ([`DataClipboard::copy`](super::wayland::DataClipboard::copy)). fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let cb = self.cb(Asker::User)?; + #[cfg(target_os = "linux")] + if let Backend::Wayland(wl) = cb { + wl.copy(flavours)?; + // What a read got late was on the clipboard before this write. + wl.forget_unanswered(); + self.ours = wl.holds_ours().then_some(Ours::Source); + return Ok(()); + } let text = text_of(flavours); let image = match &text { Some(_) => None, None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)), }; - let cb = self.cb(Asker::User)?; let ours = match (text, image) { (Some(t), _) => { cb.set_text(t.as_str())?; @@ -1281,6 +1306,8 @@ mod portable { match &self.ours { Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t), Some(Ours::Image(w, h)) => cb.image_now().is_ok_and(|now| (now.width, now.height) == (*w, *h)), + #[cfg(target_os = "linux")] + Some(Ours::Source) => cb.holds_source(), None => false, } } @@ -1307,7 +1334,8 @@ mod portable { read } - /// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no + /// Text, on macOS vector art or a bitmap (by the pasteboard's types), and on the Wayland + /// core protocol SVG, text or PNG (by the types offered, nothing read). arboard has no /// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys /// take it, the menu item waits for text. fn has(&mut self, mimes: &[&'static str]) -> bool { @@ -1315,7 +1343,12 @@ mod portable { if super::mac::has(mimes) { return true; } - mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty())) + let Ok(cb) = self.cb(Asker::Probe) else { return false }; + #[cfg(target_os = "linux")] + if let Backend::Wayland(wl) = cb { + return wl.has(mimes); + } + mimes.contains(&TEXT) && cb.get_text().is_ok_and(|t| !t.is_empty()) } } @@ -1656,45 +1689,58 @@ mod portable { #[cfg(target_os = "linux")] #[test] fn a_paste_after_an_unanswered_core_protocol_text_read_waits_for_its_answer() { - use super::super::wayland::TextReads; + use std::sync::{Arc, Mutex}; + + use super::super::wayland::{Reads, Seen}; use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; - /// Core protocol text reads whose owner answers with `text` only once the sender + /// Core protocol reads of a clipboard offering `types`, whose owner answers each read + /// of a text type with `text` and of `image/png` with `png`, only once the sender /// returned is used or dropped. - fn slow_text(text: &'static str) -> (TextReads, std::sync::mpsc::Sender<()>) { + fn slow_owner(types: &[&str], text: &'static str, png: &'static [u8]) -> (Reads, Arc>, std::sync::mpsc::Sender<()>) { let (release, gate) = std::sync::mpsc::channel::<()>(); - let loads = Loads::spawn("test", Duration::ZERO, move |()| { + let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| { let _ = gate.recv(); - Ok(text.to_string()) + Ok(if wire == PNG { png.to_vec() } else { text.as_bytes().to_vec() }) }) .unwrap(); - (TextReads::new(loads), release) + let seen = Arc::new(Mutex::new(Seen::offering(types))); + (Reads::new(loads, seen.clone()), seen, release) } - // Text was copied here; the clipboard's owner answers the check with `text` late. - let paste_after_check = |text: &'static str| { - let (mut reads, release) = slow_text(text); + // The clipboard's owner answers the check with `text` late; `copied_here` when the + // clipboard holds the Copy made here meanwhile. + let paste_after_check = |text: &'static str, copied_here: bool| { + let (mut reads, seen, release) = slow_owner(&["text/plain;charset=utf-8", PNG], text, b"\x89PNG"); // The check whether the clipboard still holds what was copied here gets no answer // in its wait (2 s on the UI thread, none here). - assert!(reads.get(Duration::ZERO).is_err()); + assert!(reads.text(Duration::ZERO).is_err()); // The paste's own read fails at once while the owner hasn't answered. - assert!(reads.get(Duration::from_secs(600)).is_err()); + assert!(reads.text(Duration::from_secs(600)).is_err()); // A paste of a bitmap alone can't take text: the read stays kept. assert!(reads.unanswered(&[BITMAP]).is_none()); let late = Late::Wayland(reads.unanswered(&PASTE_ORDER).expect("the check's read is kept for the paste")); - let ours = Ours::Text("copied here".into()); - let paste = std::thread::spawn(move || late.paste(Some(&ours), &PASTE_ORDER)); + if copied_here { + let mut seen = seen.lock().unwrap(); + seen.copy(vec!["text/plain;charset=utf-8", PNG]); + seen.selection(vec!["text/plain;charset=utf-8".into(), PNG.into()]); + } + let paste = std::thread::spawn(move || late.paste(None, &PASTE_ORDER)); drop(release); paste.join().ok().flatten() }; - let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted"); + let pasted = paste_after_check("copied elsewhere", false).expect("the other app's text is pasted"); assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec())); - // The text copied here, or nothing: the paste takes the internal clipboard. - assert!(paste_after_check("copied here").is_none()); - assert!(paste_after_check("").is_none()); + // Text that only names a picture goes with it: the picture is pasted, as at once. + let pasted = paste_after_check("https://example.com/art.png", false).expect("the picture is pasted"); + assert_eq!((pasted.mime, pasted.data), (PNG, b"\x89PNG".to_vec())); + // The Copy made here, by its data source: the paste takes the internal clipboard. + assert!(paste_after_check("copied elsewhere", true).is_none()); + // Empty text is not pasted: the picture beside it is. + assert_eq!(paste_after_check("", false).map(|f| f.mime), Some(PNG)); // An answer that came before the paste may be old: it is dropped, not pasted. - let (mut reads, release) = slow_text("old"); - assert!(reads.get(Duration::ZERO).is_err()); + let (mut reads, _, release) = slow_owner(&["text/plain"], "old", b""); + assert!(reads.text(Duration::ZERO).is_err()); drop(release); while reads.waiting() { std::thread::yield_now(); @@ -1705,14 +1751,14 @@ mod portable { // again and takes the new owner's text. let (release, gate) = std::sync::mpsc::channel::<()>(); let mut texts_read = 0; - let loads = Loads::spawn("test", Duration::ZERO, move |()| { + let loads = Loads::spawn("test", Duration::ZERO, move |_: String| { let _ = gate.recv(); texts_read += 1; - Ok(if texts_read == 1 { "old owner" } else { "new owner" }.to_string()) + Ok(if texts_read == 1 { "old owner" } else { "new owner" }.as_bytes().to_vec()) }) .unwrap(); - let mut reads = TextReads::new(loads); - assert!(reads.get(Duration::ZERO).is_err()); + let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&["UTF8_STRING"])))); + assert!(reads.text(Duration::ZERO).is_err()); let late = Late::Wayland(reads.unanswered(&[TEXT]).expect("the read is kept for the paste")); let paste = std::thread::spawn(move || late.paste(None, &[TEXT])); drop(release); @@ -1737,81 +1783,311 @@ mod portable { /// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the /// window's own Wayland connection: what Copy and Paste use where the compositor lacks the /// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on -/// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the -/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's -/// data with no limit, so reads run on a worker of their own. A read that gets no answer in that -/// time is kept for a paste to wait for off the UI thread, as arboard's are. +/// a Wayland session). A Copy offers its SVG as `image/svg+xml`, its PNG as `image/png` and its +/// text under each of [`TEXT_TYPES`]; Paste reads the clipboard's SVG, text and PNG, and the paste +/// check looks at the types offered without reading any data. The protocol gives the clipboard to +/// the window with the keyboard focus only. A worker thread handles the protocol's events and +/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads +/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and +/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for +/// a paste to wait for off the UI thread, as arboard's are. #[cfg(target_os = "linux")] mod wayland { - use std::sync::{Arc, mpsc}; + use std::io::{Read, Write}; + use std::os::fd::AsFd as _; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc}; use std::time::{Duration, Instant}; - use super::TEXT; + use vectorcraft_engine::cmd::clipboard::names_a_picture; + use vectorcraft_ui_egui::ClipboardRead; + use wayland_client::backend::ObjectData; + use wayland_client::globals::{GlobalListContents, registry_queue_init}; + use wayland_client::protocol::wl_callback::{self, WlCallback}; + use wayland_client::protocol::wl_data_device::{self, WlDataDevice}; + use wayland_client::protocol::wl_data_device_manager::{self, WlDataDeviceManager}; + use wayland_client::protocol::wl_data_offer::{self, WlDataOffer}; + use wayland_client::protocol::wl_data_source::{self, WlDataSource}; + use wayland_client::protocol::wl_keyboard::{self, WlKeyboard}; + use wayland_client::protocol::wl_pointer::{self, WlPointer}; + use wayland_client::protocol::wl_registry::{self, WlRegistry}; + use wayland_client::protocol::wl_seat::{self, WlSeat}; + use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum}; + use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer}; + use super::{BITMAP, Flavour, PNG, SVG, TEXT}; - /// What a text read gives. - type Text = Result; + /// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK + /// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`. + const TEXT_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"]; + /// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own + /// limit: a read of more fails. + const MAX_BYTES: usize = 64 << 20; + /// The most pastes of a Copy's data that are sent at once; a paste beyond them gets nothing. + const MAX_SENDS: usize = 8; + /// The most types of an offer that are kept (untrusted data). + const MAX_TYPES: usize = 256; - pub struct TextClipboard { - /// Writes: a store only hands the text to smithay-clipboard's thread. - cb: smithay_clipboard::Clipboard, - /// Reads, on a worker with a second clipboard on the same connection. - reads: TextReads, - /// Held as long as the clipboards: their connection is the window's. - _window: Arc, + /// What a read gives: the bytes, or why there are none. + type Got = Result, String>; + + fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) } - /// Text reads on a worker, and the last one that got no answer in its wait. - pub(super) struct TextReads { - loads: Loads<(), Text>, - /// The last read that got no answer in its wait, until a paste takes it or the next read - /// is sent. + /// The types a Copy of `flavours` offers, each with its data: SVG as `image/svg+xml`, PNG as + /// `image/png` and text under each of [`TEXT_TYPES`]. Flavours with no data and other flavours + /// (PDF) are not offered. + fn copy_types(flavours: &[Flavour]) -> Vec<(&'static str, Arc<[u8]>)> { + let mut out: Vec<(&'static str, Arc<[u8]>)> = vec![]; + for f in flavours.iter().filter(|f| !f.data.is_empty()) { + let types: &[&'static str] = match f.mime { + SVG => &[SVG], + PNG => &[PNG], + TEXT => &TEXT_TYPES, + _ => &[], + }; + let data: Arc<[u8]> = f.data.as_slice().into(); + for &t in types { + if !out.iter().any(|(o, _)| *o == t) { + out.push((t, data.clone())); + } + } + } + out + } + + /// The type among `offered` that a read of `mime` asks for, in any letter case: SVG as + /// `image/svg+xml`, text as the first of [`TEXT_TYPES`] offered, a bitmap as `image/png`. + fn wire_type<'a>(mime: &str, offered: &'a [String]) -> Option<&'a str> { + let wanted: &[&str] = match mime { + SVG => &[SVG], + TEXT => &TEXT_TYPES, + BITMAP | PNG => &[PNG], + _ => &[], + }; + wanted.iter().find_map(|w| offered.iter().find(|o| o.eq_ignore_ascii_case(w))).map(String::as_str) + } + + /// The flavour a read of `mime` makes of `data`: SVG as it is, a bitmap as PNG, text with its + /// line ends as `\n`. None when `data` is empty. + fn flavour(mime: &'static str, data: Vec) -> Option { + if data.is_empty() { + return None; + } + Some(match mime { + TEXT => Flavour { mime: TEXT, data: String::from_utf8_lossy(&data).replace("\r\n", "\n").replace('\r', "\n").into_bytes() }, + BITMAP | PNG => Flavour { mime: PNG, data }, + _ => Flavour { mime, data }, + }) + } + + /// What a paste of one of `mimes` takes from a clipboard offering `offered`, `read` reading the + /// offer's type for each one tried: the first of `mimes` offered with data, except that text + /// that is blank or only names a picture gives way to the PNG when `mimes` has a bitmap + /// (`adopt_system_clipboard_or_defer` in `vectorcraft-ui-egui`). + fn pick(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option { + let mut take = |mime: &'static str| flavour(mime, read(wire_type(mime, offered)?)?); + let first = mimes.iter().find_map(|&m| take(m))?; + if first.mime == TEXT && mimes.contains(&BITMAP) { + let text = String::from_utf8_lossy(&first.data); + if text.trim().is_empty() || names_a_picture(&text) { + return take(BITMAP).or(Some(first)); + } + } + Some(first) + } + + /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read. + fn read_capped(from: impl Read, cap: usize) -> Got { + let mut data = vec![]; + from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?; + if data.len() > cap { + return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20)); + } + Ok(data) + } + + /// The threads that send a Copy's data to the apps that paste it, counted. + #[derive(Clone, Default)] + struct Sends(Arc); + + impl Sends { + /// `data` written to `to` on a thread of its own, which then closes `to`; an app that + /// closes its end early ends the write (the process ignores `SIGPIPE`). While + /// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false. + fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool { + if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS { + self.0.fetch_sub(1, Ordering::SeqCst); + return false; + } + let count = self.0.clone(); + let send = move || { + let _ = to.write_all(&data); + drop(to); + count.fetch_sub(1, Ordering::SeqCst); + }; + if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() { + self.0.fetch_sub(1, Ordering::SeqCst); + return false; + } + true + } + + /// How many are being written. + #[cfg(test)] + fn running(&self) -> usize { + self.0.load(Ordering::SeqCst) + } + } + + /// What the protocol worker has seen of the clipboard. + #[derive(Default)] + pub(super) struct Seen { + /// The types of the clipboard's offer, none when it is empty. + offered: Vec, + /// The types of this connection's last Copy, until its data source is canceled. + copied: Option>, + /// Whether the clipboard holds that Copy. + ours: bool, + } + + impl Seen { + /// A clipboard offering `types`, copied elsewhere. + #[cfg(test)] + pub(super) fn offering(types: &[&str]) -> Self { + Self { offered: types.iter().map(|t| t.to_string()).collect(), ..Self::default() } + } + + /// A Copy of `types` (none: the clipboard emptied) was given the clipboard. + pub(super) fn copy(&mut self, types: Vec<&'static str>) { + self.ours = !types.is_empty(); + self.copied = Some(types).filter(|t| !t.is_empty()); + } + + /// The clipboard's offer has `offered` now. It holds the last Copy while that Copy's data + /// source is not canceled and the offer has the Copy's types, no more and no fewer, as the + /// compositor offers a source's types. An offer made before the Copy and handled after it + /// makes the Copy not ours until the Copy's own offer comes. + pub(super) fn selection(&mut self, offered: Vec) { + self.ours = self.copied.as_ref().is_some_and(|c| c.len() == offered.len() && c.iter().all(|t| offered.iter().any(|o| o == t))); + self.offered = offered; + } + + /// The last Copy's data source was canceled: another app holds the clipboard. + pub(super) fn cancelled(&mut self) { + self.copied = None; + self.ours = false; + } + } + + /// Reads of the clipboard's offer on a worker, and the last one that got no answer in its wait. + pub(super) struct Reads { + /// Each request is the offer's type to read. + loads: Loads, + seen: Arc>, + /// The last text read that got no answer in its wait, until a paste takes it or the next + /// read is sent. unanswered: Option, } /// A text read that got no answer in its wait. pub(super) struct Unanswered { - answer: mpsc::Receiver, + answer: mpsc::Receiver, sent: Instant, - /// The worker, for the read made again once the answer comes. - reads: Asker<(), Text>, + /// The worker, for the reads made once the answer comes. + reads: Asker, + seen: Arc>, } impl Unanswered { - /// The text, read again once this read's answer comes, waiting for that answer until - /// `limit` after the read was sent, and for the new one as long, [`LOAD_WAIT`] at least. - /// The late answer may come from an app that lost the clipboard while it was awaited. - pub(super) fn text(self, limit: Duration) -> Text { + /// What a paste of one of `mimes` takes once this read's answer comes, waiting for it until + /// `limit` after the read was sent: the clipboard read again as [`pick`] reads it, each read + /// waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may have lost the + /// clipboard meanwhile. None when the answer doesn't come or the clipboard holds this + /// connection's Copy. + pub(super) fn paste(self, mimes: &[&'static str], limit: Duration) -> Option { let left = |now: Instant| (self.sent + limit).saturating_duration_since(now); - let _late = wait_for_answer(&self.answer, left(Instant::now()))?; - wait_for_answer(&self.reads.ask(())?, LOAD_WAIT.max(left(Instant::now())))? + let _late = wait_for_answer(&self.answer, left(Instant::now())).ok()?; + let offered = { + let seen = lock(&self.seen); + if seen.ours { + return None; + } + seen.offered.clone() + }; + pick(&offered, mimes, |wire| wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok()) } } - impl TextReads { - pub(super) fn new(loads: Loads<(), Text>) -> Self { - Self { loads, unanswered: None } + impl Reads { + pub(super) fn new(loads: Loads, seen: Arc>) -> Self { + Self { loads, seen, unanswered: None } } - /// The text, waiting `wait` at most. A read that gets no answer in that time is kept - /// ([`Self::unanswered`]) for a paste to wait for off the UI thread; a read sent means the - /// one kept has been answered, and its answer is dropped. - pub(super) fn get(&mut self, wait: Duration) -> Text { + /// The offer's type for `mime`, when it has one. + fn wire(&self, mime: &str) -> Option { + wire_type(mime, &lock(&self.seen).offered).map(str::to_string) + } + + /// The text, waiting `wait` at most; empty when the clipboard has none. A read that gets no + /// answer in that time is kept ([`Self::unanswered`]) for a paste to wait for off the UI + /// thread; a read sent means the one kept has been answered, and its answer is dropped. + pub(super) fn text(&mut self, wait: Duration) -> Result { + let Some(wire) = self.wire(TEXT) else { return Ok(String::new()) }; let sent = Instant::now(); - let answer = self.loads.ask(())?; + let answer = self.loads.ask(wire)?; self.unanswered = None; match wait_for_answer(&answer, wait) { - Ok(text) => text, + Ok(got) => Ok(flavour(TEXT, got?).map(|f| String::from_utf8_lossy(&f.data).into_owned()).unwrap_or_default()), Err(why) => { if why != GONE { - self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker() }); + self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker(), seen: self.seen.clone() }); } Err(why) } } } - /// The read that got no answer in its wait, while its answer has still not come and a + /// What a paste of one of `mimes` reads: the first the clipboard offers. Text is read here, + /// [`LOAD_WAIT`] at most, and pasted unless it is empty; SVG and PNG are asked for here and + /// waited for, `wait` at most, by the work that comes back. When that read fails or is + /// empty, the work reads the rest of `mimes` as [`pick`] does, each read waiting until + /// `wait` after the first was sent, [`LOAD_WAIT`] at least. + pub(super) fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option { + mimes.iter().enumerate().find_map(|(i, &mime)| { + let wire = self.wire(mime)?; + if mime == TEXT { + let text = self.text(LOAD_WAIT).ok().filter(|t| !t.is_empty())?; + return Some(ClipboardRead::Now(Flavour { mime: TEXT, data: text.into_bytes() })); + } + let sent = Instant::now(); + let answer = self.loads.ask(wire).ok()?; + self.unanswered = None; + let (reads, seen, rest) = (self.loads.asker(), self.seen.clone(), mimes.get(i + 1..).unwrap_or_default().to_vec()); + Some(ClipboardRead::Later(Box::new(move || { + if let Some(f) = wait_for_answer(&answer, wait).ok().and_then(Result::ok).and_then(|data| flavour(mime, data)) { + return Some(f); + } + let offered = lock(&seen).offered.clone(); + let left = || LOAD_WAIT.max((sent + wait).saturating_duration_since(Instant::now())); + pick(&offered, &rest, |wire| wait_for_answer(&reads.ask(wire.to_string()).ok()?, left()).ok()?.ok()) + }))) + }) + } + + /// Whether the clipboard offers one of `mimes`; nothing is read. + pub(super) fn has(&self, mimes: &[&'static str]) -> bool { + let seen = lock(&self.seen); + mimes.iter().any(|m| wire_type(m, &seen.offered).is_some()) + } + + /// Whether the clipboard holds this connection's last Copy. + pub(super) fn holds_ours(&self) -> bool { + lock(&self.seen).ours + } + + /// The text read that got no answer in its wait, while its answer has still not come and a /// paste of one of `mimes` can take text. Once it has come the answer may be old, and it is /// dropped: a new read is made. pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { @@ -1819,6 +2095,11 @@ mod wayland { self.loads.waiting().then_some(unanswered) } + /// Drops the read that got no answer in its wait: a paste no longer takes its answer. + pub(super) fn forget_unanswered(&mut self) { + self.unanswered = None; + } + /// Whether a read sent is still unanswered. #[cfg(test)] pub(super) fn waiting(&self) -> bool { @@ -1826,55 +2107,591 @@ mod wayland { } } - /// The read worker's clipboard and the window whose connection it uses, dropped in that order. - struct Loader { - cb: smithay_clipboard::Clipboard, + /// The protocol objects of a [`DataClipboard`], kept up to date by its worker. + #[derive(Default)] + struct Objects { + device: Option, + keyboard: Option, + pointer: Option, + /// The serial of the last key press, click or keyboard focus, which a Copy takes the + /// clipboard with. + serial: Option, + /// The clipboard's offer. + selection: Option, + /// The offer of a drag over the window. + drag: Option, + /// The last Copy's data source. + source: Option, + } + + /// An offer's types, as its events give them. + #[derive(Default)] + struct OfferData(Mutex>); + + /// A Copy's types with their data. + struct SourceData(Vec<(&'static str, Arc<[u8]>)>); + + /// What the protocol worker's event handlers change. + struct State { + objects: Arc>, + seen: Arc>, + sends: Sends, + } + + impl State { + /// Lets go of every object, once the clipboard is dropped. + fn close(&mut self, seat: &WlSeat) { + let mut o = lock(&self.objects); + for offer in [o.selection.take(), o.drag.take()].into_iter().flatten() { + offer.destroy(); + } + if let Some(source) = o.source.take() { + source.destroy(); + } + if let Some(device) = o.device.take().filter(|d| d.version() >= 2) { + device.release(); + } + if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) { + keyboard.release(); + } + if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) { + pointer.release(); + } + if seat.version() >= 5 { + seat.release(); + } + } + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlCallback, _: wl_callback::Event, _: &(), _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlDataDeviceManager, _: wl_data_device_manager::Event, _: &(), _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(state: &mut Self, seat: &WlSeat, event: wl_seat::Event, _: &(), _: &Connection, qh: &QueueHandle) { + let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event else { return }; + let mut o = lock(&state.objects); + if !caps.contains(wl_seat::Capability::Keyboard) { + if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) { + keyboard.release(); + } + } else if o.keyboard.is_none() { + o.keyboard = Some(seat.get_keyboard(qh, ())); + } + if !caps.contains(wl_seat::Capability::Pointer) { + if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) { + pointer.release(); + } + } else if o.pointer.is_none() { + o.pointer = Some(seat.get_pointer(qh, ())); + } + } + } + + /// The serial a Copy takes the clipboard with after the keyboard event `event`, `serial` before + /// it: a key or the keyboard focus coming in sets it, the focus leaving clears it, and other + /// events leave it. A modifier change's serial is not an input event's, and the release of Ctrl + /// after Ctrl+C comes before the Copy's data is ready. + fn keyboard_serial(event: &wl_keyboard::Event, serial: Option) -> Option { + match event { + wl_keyboard::Event::Enter { serial, .. } | wl_keyboard::Event::Key { serial, .. } => Some(*serial), + wl_keyboard::Event::Leave { .. } => None, + _ => serial, + } + } + + impl Dispatch for State { + fn event(state: &mut Self, _: &WlKeyboard, event: wl_keyboard::Event, _: &(), _: &Connection, _: &QueueHandle) { + let mut o = lock(&state.objects); + o.serial = keyboard_serial(&event, o.serial); + } + } + + impl Dispatch for State { + fn event(state: &mut Self, _: &WlPointer, event: wl_pointer::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let wl_pointer::Event::Button { serial, .. } = event { + lock(&state.objects).serial = Some(serial); + } + } + } + + impl Dispatch for State { + fn event(state: &mut Self, _: &WlDataDevice, event: wl_data_device::Event, _: &(), _: &Connection, _: &QueueHandle) { + let mut o = lock(&state.objects); + match event { + wl_data_device::Event::Selection { id } => { + let mut offered = id.as_ref().and_then(|offer| offer.data::()).map(|d| lock(&d.0).clone()).unwrap_or_default(); + offered.dedup(); + if let Some(old) = std::mem::replace(&mut o.selection, id) { + old.destroy(); + } + lock(&state.seen).selection(offered); + } + wl_data_device::Event::Enter { id, .. } => { + if let Some(old) = std::mem::replace(&mut o.drag, id) { + old.destroy(); + } + } + wl_data_device::Event::Leave | wl_data_device::Event::Drop => { + if let Some(old) = o.drag.take() { + old.destroy(); + } + } + _ => {} + } + } + + /// The one event of a data device that makes an object is `data_offer`. + fn event_created_child(_: u16, qh: &QueueHandle) -> Arc { + qh.make_data::(OfferData::default()) + } + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlDataOffer, event: wl_data_offer::Event, data: &OfferData, _: &Connection, _: &QueueHandle) { + if let wl_data_offer::Event::Offer { mime_type } = event { + let mut types = lock(&data.0); + if types.len() < MAX_TYPES { + types.push(mime_type); + } + } + } + } + + impl Dispatch for State { + fn event(state: &mut Self, source: &WlDataSource, event: wl_data_source::Event, data: &SourceData, _: &Connection, _: &QueueHandle) { + match event { + // A type the Copy doesn't offer gets nothing: dropping `fd` closes it. + wl_data_source::Event::Send { mime_type, fd } => { + if let Some((_, bytes)) = data.0.iter().find(|(t, _)| t.eq_ignore_ascii_case(&mime_type)) { + state.sends.start(std::fs::File::from(fd), bytes.clone()); + } + } + wl_data_source::Event::Cancelled => { + source.destroy(); + let mut o = lock(&state.objects); + if o.source.as_ref() == Some(source) { + o.source = None; + lock(&state.seen).cancelled(); + } + } + _ => {} + } + } + } + + /// Reads the clipboard's offer for the read worker. + struct Receiver { + conn: Connection, + objects: Arc>, + /// Held as long as the connection, which is the window's. _window: Arc, } - impl Loader { - fn load(&self) -> Result { - self.cb.load().map_err(|e| e.to_string()) + impl Receiver { + /// The clipboard's data of type `wire`, read until the app that copied closes the pipe, + /// [`MAX_BYTES`] at most. + fn receive(&self, wire: String) -> Got { + let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?; + let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?; + offer.receive(wire, to.as_fd()); + // The request carries a copy of the write end: once it is sent, the app that copied + // holds the only one, and the read ends when that app closes it. + drop(to); + self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?; + read_capped(from, MAX_BYTES) } } - impl TextClipboard { - /// None when `window` isn't a Wayland window or the read worker can't start. - #[allow(unsafe_code)] + pub struct DataClipboard { + reads: Reads, + objects: Arc>, + manager: WlDataDeviceManager, + conn: Connection, + qh: QueueHandle, + /// Tells the protocol worker to stop. + stop: Arc, + /// Held as long as the connection, which is the window's. + _window: Arc, + } + + impl DataClipboard { + /// None when `window` isn't a Wayland window, the compositor has no data-device manager or + /// seat, or a worker can't start. pub fn new(window: Arc) -> Option { - use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle}; - let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None }; - let display = d.display.as_ptr(); - // SAFETY: the display is winit's open `wl_display`, which outlives `window`; each - // clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard - // makes its own event queue on the display and doesn't close it. - let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) }; - let loader = Loader { cb: reader, _window: window.clone() }; - // `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names, - // and the worker must hold the window as long as its clipboard. + let conn = connection(&window)?; + let (globals, mut queue) = registry_queue_init::(&conn).ok()?; + let qh = queue.handle(); + let manager: WlDataDeviceManager = globals.bind(&qh, 1..=3, ()).ok()?; + let seat: WlSeat = globals.bind(&qh, 1..=5, ()).ok()?; + let (objects, seen) = (Arc::>::default(), Arc::>::default()); + lock(&objects).device = Some(manager.get_data_device(&seat, &qh, ())); + let mut state = State { objects: objects.clone(), seen: seen.clone(), sends: Sends::default() }; + // The seat's keyboard and pointer, and the clipboard's offer when the window has focus. + queue.roundtrip(&mut state).ok()?; + let receiver = Receiver { conn: conn.clone(), objects: objects.clone(), _window: window.clone() }; // Dropping doesn't wait for an unanswered read: only arboard's X11 handles hand what the // app copied on when the last one goes. - let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |()| loader.load())?; - Some(Self { cb, reads: TextReads::new(loads), _window: window }) + let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |wire| receiver.receive(wire))?; + let stop = Arc::new(AtomicBool::new(false)); + let (stopped, held) = (stop.clone(), window.clone()); + let work = move || { + while !stopped.load(Ordering::SeqCst) && queue.blocking_dispatch(&mut state).is_ok() {} + state.close(&seat); + let _ = queue.flush(); + drop(held); + }; + std::thread::Builder::new().name("wayland-clipboard".into()).spawn(work).ok()?; + Some(Self { reads: Reads::new(loads, seen), objects, manager, conn, qh, stop, _window: window }) } - pub fn get_text(&mut self) -> Result { - self.reads.get(LOAD_WAIT) + /// Gives the clipboard to a Copy of `flavours` ([`copy_types`]); none empties it. Err when + /// the seat has no data device or the window has had no input to take the clipboard with. + pub fn copy(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let offered = copy_types(flavours); + let mut o = lock(&self.objects); + let device = o.device.clone().ok_or("the seat has no clipboard")?; + let serial = o.serial.ok_or("the window has had no key press or click to copy with")?; + let types: Vec<&'static str> = offered.iter().map(|(t, _)| *t).collect(); + let source = (!offered.is_empty()).then(|| { + let source = self.manager.create_data_source(&self.qh, SourceData(offered)); + for t in &types { + source.offer(t.to_string()); + } + source + }); + device.set_selection(source.as_ref(), serial); + if let Some(old) = std::mem::replace(&mut o.source, source) { + old.destroy(); + } + lock(&self.reads.seen).copy(types); + drop(o); + self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}")) } - pub fn set_text(&self, text: &str) { - self.cb.store(text); + pub fn text(&mut self) -> Result { + self.reads.text(LOAD_WAIT) + } + + /// What a paste of one of `mimes` reads ([`Reads::read`]), SVG and PNG waited for `wait` at + /// most. + pub fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option { + self.reads.read(mimes, wait) + } + + pub fn has(&self, mimes: &[&'static str]) -> bool { + self.reads.has(mimes) + } + + pub fn holds_ours(&self) -> bool { + self.reads.holds_ours() } /// The read that got no answer in its wait, when a paste of one of `mimes` can take its - /// answer ([`TextReads::unanswered`]). + /// answer ([`Reads::unanswered`]). pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { self.reads.unanswered(mimes) } /// Drops the read that got no answer in its wait: a paste no longer takes its answer. pub fn forget_unanswered(&mut self) { - self.reads.unanswered = None; + self.reads.forget_unanswered(); + } + } + + impl Drop for DataClipboard { + /// Stops the protocol worker, which a sync request's answer wakes. + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = self.conn.display().sync(&self.qh, ()); + let _ = self.conn.flush(); + } + } + + /// A connection on `window`'s Wayland display. + #[allow(unsafe_code)] + fn connection(window: &winit::window::Window) -> Option { + use wayland_backend::sys::client::Backend; + use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle}; + let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None }; + // SAFETY: the display is winit's open `wl_display`, which outlives `window`; every user of + // the connection holds a clone of `window`. A backend made from a foreign display does not + // close it when dropped. + let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) }; + Some(Connection::from_backend(backend)) + } + + #[cfg(test)] + mod tests { + use super::*; + + fn flavour_of(mime: &'static str, data: &[u8]) -> Flavour { + Flavour { mime, data: data.to_vec() } + } + + fn offered(types: &[&str]) -> Vec { + types.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn a_copy_offers_its_svg_png_and_text_each_with_its_own_data() { + let flavours = [flavour_of(TEXT, b""), flavour_of(SVG, b""), flavour_of(super::super::PDF, b"%PDF"), flavour_of(PNG, b"png")]; + let types: Vec<(&str, Vec)> = copy_types(&flavours).into_iter().map(|(t, d)| (t, d.to_vec())).collect(); + assert_eq!( + types, + [ + ("text/plain;charset=utf-8", b"".to_vec()), + ("UTF8_STRING", b"".to_vec()), + ("text/plain", b"".to_vec()), + (SVG, b"".to_vec()), + (PNG, b"png".to_vec()), + ] + ); + // With Include SVG Code off the engine makes no SVG flavour, and none is offered; a + // flavour with no data is not offered either. + let types: Vec<&str> = copy_types(&[flavour_of(PNG, b"png"), flavour_of(TEXT, b"")]).into_iter().map(|(t, _)| t).collect(); + assert_eq!(types, [PNG]); + assert!(copy_types(&[]).is_empty()); + } + + #[test] + fn a_read_asks_for_the_offers_own_type() { + let all = offered(&["text/plain", "TEXT", "UTF8_STRING", "Image/PNG", "image/svg+xml", "application/pdf"]); + assert_eq!(wire_type(TEXT, &all), Some("UTF8_STRING")); + assert_eq!(wire_type(TEXT, &offered(&["text/plain", "text/plain;charset=UTF-8"])), Some("text/plain;charset=UTF-8")); + assert_eq!(wire_type(BITMAP, &all), Some("Image/PNG")); + assert_eq!(wire_type(SVG, &all), Some("image/svg+xml")); + // PDF, EPS and metafiles are not read here, nor types no reader knows. + assert_eq!(wire_type(super::super::PDF, &all), None); + assert_eq!(wire_type(TEXT, &offered(&["text/html", "image/jpeg"])), None); + assert_eq!(wire_type(SVG, &[]), None); + } + + /// What a paste of `mimes` takes from a clipboard holding `data` by type, and the types read. + fn pasted(data: &[(&str, &[u8])], mimes: &[&'static str]) -> (Option, Vec) { + let types: Vec = data.iter().map(|(t, _)| t.to_string()).collect(); + let mut read = vec![]; + let f = pick(&types, mimes, |wire| { + read.push(wire.to_string()); + data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec()) + }); + (f, read) + } + + #[test] + fn a_paste_takes_svg_then_text_then_png() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let svg: (&str, &[u8]) = (SVG, b""); + let text: (&str, &[u8]) = ("text/plain;charset=utf-8", b"a\r\nb"); + let png: (&str, &[u8]) = (PNG, b"\x89PNG"); + let (f, read) = pasted(&[text, png, svg], &PASTE_ORDER); + assert_eq!(f.map(|f| (f.mime, f.data)), Some((SVG, svg.1.to_vec()))); + assert_eq!(read, [SVG]); + // Text before a bitmap, its line ends made `\n`. + let (f, _) = pasted(&[png, text], &PASTE_ORDER); + assert_eq!(f.map(|f| (f.mime, f.data)), Some((TEXT, b"a\nb".to_vec()))); + // An offer of a PNG or SVG alone pastes it. + assert_eq!(pasted(&[png], &PASTE_ORDER).0.map(|f| (f.mime, f.data)), Some((PNG, png.1.to_vec()))); + assert_eq!(pasted(&[svg], &PASTE_ORDER).0.map(|f| f.mime), Some(SVG)); + // Blank text, or text that only names a picture, gives way to the picture. + for beside in [&b" \n"[..], b"https://example.com/art.png", b""] { + let (f, read) = pasted(&[("UTF8_STRING", beside), png], &PASTE_ORDER); + assert_eq!(f.map(|f| f.mime), Some(PNG)); + assert_eq!(read, ["UTF8_STRING", PNG]); + } + // With no picture, or a paste of text alone, the text is what there is. + assert_eq!(pasted(&[("text/plain", &b"https://example.com/art.png"[..])], &PASTE_ORDER).0.map(|f| f.mime), Some(TEXT)); + assert_eq!(pasted(&[("text/plain", &b" "[..]), png], &[TEXT]).0.map(|f| f.mime), Some(TEXT)); + // An empty clipboard, a type no reader knows, data that doesn't come or is empty. + assert_eq!(pasted(&[], &PASTE_ORDER).0, None); + assert_eq!(pasted(&[("application/x-other", &b"x"[..])], &PASTE_ORDER).0, None); + assert_eq!(pasted(&[(PNG, &b""[..])], &PASTE_ORDER).0, None); + let types = offered(&[PNG]); + assert_eq!(pick(&types, &PASTE_ORDER, |_| None), None); + } + + #[test] + fn the_clipboard_holds_a_copy_until_another_app_takes_it() { + let mut seen = Seen::default(); + let types = vec!["text/plain;charset=utf-8", SVG, PNG]; + seen.copy(types.clone()); + assert!(seen.ours, "a Copy holds the clipboard before its offer comes"); + // An offer made before the Copy and handled after it. + seen.selection(offered(&[PNG])); + assert!(!seen.ours); + // The Copy's own offer, its types in another order. + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"])); + assert!(seen.ours); + // Another app's copy: an offer with other types, then the source canceled. + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8", "text/html"])); + assert!(!seen.ours); + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"])); + seen.cancelled(); + assert!(!seen.ours, "a canceled source no longer holds the clipboard"); + // Another app's offer with the same types after that is not ours. + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"])); + assert!(!seen.ours); + // An empty Copy empties the clipboard. + seen.copy(vec![]); + assert!(!seen.ours); + seen.selection(vec![]); + assert!(!seen.ours && seen.offered.is_empty()); + } + + #[test] + fn a_copy_takes_the_clipboard_with_a_key_press_not_a_modifier_change() { + let key = |serial| wl_keyboard::Event::Key { serial, time: 0, key: 46, state: WEnum::Value(wl_keyboard::KeyState::Pressed) }; + let modifiers = |serial| wl_keyboard::Event::Modifiers { serial, mods_depressed: 0, mods_latched: 0, mods_locked: 0, group: 0 }; + // Ctrl+C, then Ctrl released before the Copy's data is ready. + let serial = keyboard_serial(&key(7), None); + assert_eq!(keyboard_serial(&modifiers(8), serial), Some(7)); + assert_eq!(keyboard_serial(&wl_keyboard::Event::RepeatInfo { rate: 25, delay: 600 }, Some(7)), Some(7)); + assert_eq!(keyboard_serial(&modifiers(9), None), None); + } + + #[test] + fn a_read_stops_at_its_limit() { + assert_eq!(MAX_BYTES, 64 * 1024 * 1024); + assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec())); + assert_eq!(read_capped(&b""[..], 8), Ok(vec![])); + assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string())); + assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string())); + /// A pipe that breaks. + struct Broken; + impl Read for Broken { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + } + assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data"))); + } + + #[test] + fn a_copys_data_is_sent_through_the_pipe_and_closed() { + let sends = Sends::default(); + let (from, to) = std::io::pipe().unwrap(); + let data: Arc<[u8]> = vec![7u8; 300_000].into(); + assert!(sends.start(to, data.clone())); + // More than a pipe holds: the write ends once all of it is read, then the pipe closes. + assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..])); + // An app that closes its end before reading ends the send. + let (from, to) = std::io::pipe().unwrap(); + drop(from); + assert!(sends.start(to, data.clone())); + while sends.running() > 0 { + std::thread::yield_now(); + } + } + + #[test] + fn pastes_that_read_nothing_hold_a_few_sends_at_most() { + let sends = Sends::default(); + let data: Arc<[u8]> = vec![1u8; 300_000].into(); + // Apps that ask for the data and never read it. + let stalled: Vec<_> = (0..MAX_SENDS) + .map(|_| { + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, data.clone())); + from + }) + .collect(); + // The next paste gets its pipe closed at once, with nothing written. + let (from, to) = std::io::pipe().unwrap(); + assert!(!sends.start(to, data.clone())); + assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![])); + // Once those apps close their pipes, the sends end and others are sent again. + drop(stalled); + while sends.running() > 0 { + std::thread::yield_now(); + } + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, data.clone())); + assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len())); + } + + /// What a clipboard holds, by type. + type Held = &'static [(&'static str, &'static [u8])]; + const PNG_ONLY: Held = &[(PNG, b"\x89PNG")]; + const SVG_ONLY: Held = &[(SVG, b"")]; + const TEXT_ONLY: Held = &[("text/plain;charset=utf-8", b"words")]; + const EMPTY_TEXT: Held = &[("text/plain", b"")]; + const NOTHING: Held = &[]; + const UNKNOWN: Held = &[("application/x-other", b"x")]; + + /// Reads of a clipboard holding `data`, answered at once. + fn reads_of(data: Held) -> Reads { + let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| { + data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec()).ok_or_else(|| "not offered".to_string()) + }) + .unwrap(); + let types: Vec<&str> = data.iter().map(|(t, _)| *t).collect(); + Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&types)))) + } + + #[test] + fn a_picture_or_svg_alone_can_be_pasted_and_is() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let wait = Duration::from_secs(600); + for (data, mime) in [(PNG_ONLY, PNG), (SVG_ONLY, SVG)] { + let mut reads = reads_of(data); + // The paste check enables Paste from the types alone. + assert!(reads.has(&PASTE_ORDER)); + let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("{mime} is read off the UI thread") }; + assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, data[0].1.to_vec()))); + } + let mut reads = reads_of(TEXT_ONLY); + assert!(reads.has(&[TEXT]) && !reads.has(&[BITMAP, SVG])); + let Some(ClipboardRead::Now(f)) = reads.read(&PASTE_ORDER, wait) else { panic!("text is read at once") }; + assert_eq!((f.mime, f.data), (TEXT, b"words".to_vec())); + // An empty clipboard, or one with no type a paste reads, enables nothing and pastes nothing. + for data in [NOTHING, UNKNOWN] { + let mut reads = reads_of(data); + assert!(!reads.has(&PASTE_ORDER)); + assert!(reads.read(&PASTE_ORDER, wait).is_none()); + assert_eq!(reads.text(wait), Ok(String::new())); + } + // Empty text is not pasted. + assert!(reads_of(EMPTY_TEXT).read(&[TEXT], wait).is_none()); + } + + #[test] + fn an_svg_that_sends_nothing_gives_way_to_the_text_or_png_beside_it() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + const EMPTY_SVG_TEXT: Held = &[(SVG, b""), ("text/plain", b"words"), (PNG, b"\x89PNG")]; + const EMPTY_SVG_PNG: Held = &[(SVG, b""), (PNG, b"\x89PNG")]; + const EMPTY_SVG_URL: Held = &[(SVG, b""), ("text/plain", b"https://example.com/art.png"), (PNG, b"\x89PNG")]; + let wait = Duration::from_secs(600); + const EMPTY_PNG: Held = &[(PNG, b"")]; + for (data, mime, bytes) in + [(EMPTY_SVG_TEXT, TEXT, &b"words"[..]), (EMPTY_SVG_PNG, PNG, &b"\x89PNG"[..]), (EMPTY_SVG_URL, PNG, &b"\x89PNG"[..])] + { + // Held while the work runs, as the clipboard is. + let mut reads = reads_of(data); + let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the SVG is asked for") }; + assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, bytes.to_vec()))); + } + // A failed PNG read, last of all, pastes nothing. + let mut reads = reads_of(EMPTY_PNG); + let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the PNG is asked for") }; + assert!(work().is_none()); + } + + #[test] + fn a_read_that_fails_pastes_nothing() { + let loads = Loads::spawn("test", Duration::ZERO, |_: String| Err::, _>("the clipboard's data is larger than 64 MiB".to_string())) + .unwrap(); + let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&[PNG, "text/plain"])))); + assert_eq!(reads.text(Duration::from_secs(600)), Err("the clipboard's data is larger than 64 MiB".to_string())); + let Some(ClipboardRead::Later(work)) = reads.read(&[BITMAP], Duration::from_secs(600)) else { panic!("the PNG is asked for") }; + assert!(work().is_none()); } } } diff --git a/apps/vectorcraft/src/display_profile.rs b/apps/vectorcraft/src/display_profile.rs new file mode 100644 index 0000000000..2be6e27aa5 --- /dev/null +++ b/apps/vectorcraft/src/display_profile.rs @@ -0,0 +1,629 @@ +//! Reads the ICC profile of the display that shows most of the main window, for Preferences › +//! Performance › Use Monitor Profile for Canvas, and hands it to [`DisplayProfile::report`]. +//! +//! - Windows: the display's default profile for its standard color mode. A display with Advanced +//! Color on (HDR or Automatic Color Management) is converted by Windows, and nothing is read. +//! - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE` property, then +//! the screen's `_ICC_PROFILE` when only one output is active. +//! - Wayland, XWayland included: the compositor gives applications no display profile, and the +//! canvas stays sRGB. +//! - macOS: the window's layer is tagged sRGB and macOS converts it (`mac_color_space`). +//! +//! While the preference is on, a background thread reads the display every [`EVERY`] seconds, +//! and at once when the window moves to another display. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use vectorcraft_ui_egui::display_profile::{DisplayProfile, Found, Reading}; + +/// Seconds between two readings of the same display. +const EVERY: f64 = 10.0; +/// Seconds between two checks of which display shows the window. +const LOOK_EVERY: f64 = 1.0; + +/// Starts the readings from the main window's frames. +#[derive(Default)] +pub(crate) struct Reader { + /// When the last reading started, and the display it was for. + last: Option<(f64, Option)>, + /// When the window's display was last looked up. + looked: Option, + /// A reading is running. + busy: Arc, +} + +/// Clears [`Reader::busy`] when the reading thread ends, also by a panic. +struct Done(Arc); + +impl Drop for Done { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl Reader { + /// Starts a reading when one is due and none is running. `now` is the frame's time in seconds. + pub(crate) fn tick(&mut self, window: &winit::window::Window, now: f64, enabled: bool, profile: &DisplayProfile, ctx: &egui::Context) { + if !enabled || self.busy.load(Ordering::Acquire) || self.looked.is_some_and(|at| (at..at + LOOK_EVERY).contains(&now)) { + return; + } + self.looked = Some(now); + let display = window.current_monitor().and_then(|m| m.name()); + if !due(self.last.as_ref(), now, display.as_deref()) { + return; + } + self.last = Some((now, display.clone())); + profile.pending(); + self.busy.store(true, Ordering::Release); + let done = Done(self.busy.clone()); + let (profile, ctx) = (profile.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("display profile".into()).spawn(move || { + let _done = done; + if profile.report(read(display)) { + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::warn!("couldn't start reading the display profile: {e}"); + } + } +} + +/// Whether a reading of `display` is due at `now`, after the last one (`last`): none yet, another +/// display, [`EVERY`] seconds later, or a clock that went back. +fn due(last: Option<&(f64, Option)>, now: f64, display: Option<&str>) -> bool { + last.is_none_or(|(at, shown)| shown.as_deref() != display || !(*at..at + EVERY).contains(&now)) +} + +#[cfg(windows)] +fn read(display: Option) -> Reading { + let found = win::read(display.as_deref()); + Reading { display, found } +} + +#[cfg(target_os = "linux")] +fn read(display: Option) -> Reading { + let found = if wayland_session(|k| std::env::var_os(k)) { + Found::NoProfile("a Wayland session doesn't give applications the display's profile".into()) + } else { + x11::read(display.as_deref()) + }; + Reading { display, found } +} + +#[cfg(all(target_os = "macos", feature = "wgpu"))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::System("macOS converts the window to the display's profile".into()) } +} + +#[cfg(not(any(windows, target_os = "linux", all(target_os = "macos", feature = "wgpu"))))] +fn read(display: Option) -> Reading { + Reading { display, found: Found::NoProfile("this build doesn't read display profiles on this system".into()) } +} + +/// Whether the app runs in a Wayland session, read from the environment `var` gives. XWayland +/// windows count: the variables stay set for them. +#[cfg(any(target_os = "linux", test))] +fn wayland_session(var: impl Fn(&str) -> Option) -> bool { + ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty())) +} + +/// The X11 reading from colord's answer `colord` (the profile file it names, read) and the X11 +/// properties `properties`: colord's profile when its file can be read, otherwise the +/// properties' profile. When neither has one, the reason colord gave is kept. +#[cfg(any(target_os = "linux", test))] +fn x11_choice(colord: Option, String>>, properties: impl FnOnce() -> Result, String>) -> Found { + let colord_reason = match colord { + Some(Ok(Some(found @ Found::Profile { .. }))) => return found, + Some(Ok(Some(Found::NoProfile(e) | Found::System(e)))) | Some(Err(e)) => Some(e), + Some(Ok(None)) | None => None, + }; + match properties() { + Ok(Some(found)) => found, + Ok(None) => Found::NoProfile(match colord_reason { + Some(e) => format!("no profile is set for this display ({e})"), + None => "no profile is set for this display".into(), + }), + Err(e) => Found::NoProfile(e), + } +} + +/// `found` when it is not a profile or the profile can be used, otherwise why it can't, so the +/// next source is tried. +#[cfg(any(target_os = "linux", test))] +fn usable(found: Found) -> Found { + match found { + Found::Profile { icc, from } => match vectorcraft_ui_egui::display_profile::check_profile(&icc) { + Ok(()) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(format!("{from}: {e}")), + }, + other => other, + } +} + +/// The ICC file at `path`, refused before it is read when it is larger than [`MAX_PROFILE_BYTES`]. +/// +/// [`MAX_PROFILE_BYTES`]: vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES +#[cfg(any(windows, target_os = "linux", test))] +fn read_icc(path: &std::path::Path) -> Result, String> { + use std::io::Read as _; + let max = vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES; + let too_large = || format!("{} is larger than {} MiB", path.display(), max >> 20); + let file = std::fs::File::open(path).map_err(|e| format!("couldn't open {}: {e}", path.display()))?; + let len = file.metadata().map_err(|e| format!("couldn't read {}: {e}", path.display()))?.len(); + if len > max as u64 { + return Err(too_large()); + } + let mut bytes = Vec::with_capacity(len as usize); + file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("couldn't read {}: {e}", path.display()))?; + if bytes.len() > max { Err(too_large()) } else { Ok(bytes) } +} + +/// The file a profile `name` stands for: `name` itself when it is a path, otherwise the file of +/// that name in the system's color folder `dir`. +#[cfg(any(windows, test))] +fn profile_path(dir: Option<&std::path::Path>, name: &str) -> std::path::PathBuf { + match dir { + Some(dir) if !name.contains(['\\', '/']) => dir.join(name), + _ => std::path::PathBuf::from(name), + } +} + +/// `path`'s profile, or why there is none. +#[cfg(any(windows, target_os = "linux"))] +fn profile_file(path: &std::path::Path) -> Found { + let from = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); + match read_icc(path) { + Ok(icc) => Found::Profile { icc, from }, + Err(e) => Found::NoProfile(e), + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod win { + use std::path::PathBuf; + use std::sync::OnceLock; + + use vectorcraft_ui_egui::display_profile::Found; + use windows::Win32::Devices::Display::{ + DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER, + DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME, + DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig, + }; + use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, HLOCAL, LUID, LocalFree}; + use windows::Win32::Graphics::Gdi::{CreateDCW, DeleteDC}; + use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW}; + use windows::Win32::UI::ColorSystem::{ + CPST_STANDARD_DISPLAY_COLOR_MODE, CPT_ICC, GetColorDirectoryW, GetICMProfileW, WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER, + }; + use windows::core::{HRESULT, PCWSTR, PWSTR, s, w}; + + /// `ColorProfileGetDisplayDefault` and `ColorProfileGetDisplayUserScope` (mscms.dll), which + /// older Windows versions don't have. + type GetDefault = unsafe extern "system" fn(i32, LUID, u32, i32, i32, *mut PWSTR) -> HRESULT; + type GetScope = unsafe extern "system" fn(LUID, u32, *mut i32) -> HRESULT; + + /// The display path that shows a GDI device. + struct Path { + source: u32, + target_adapter: LUID, + target: u32, + } + + pub(super) fn read(display: Option<&str>) -> Found { + let Some(device) = display else { return Found::NoProfile("Windows didn't name the window's display".into()) }; + let path = match find_path(device) { + Ok(path) => path, + Err(e) => return Found::NoProfile(e), + }; + if path.as_ref().is_some_and(advanced_color) { + return Found::System("Windows manages this display's colors (Advanced Color)".into()); + } + let name = match path.as_ref().and_then(default_profile) { + Some(Ok(name)) => name, + Some(Err(e)) => return Found::NoProfile(e), + // Older Windows, or a display without an active path: GDI's profile for the device. + None => match icm_profile(device) { + Ok(name) => name, + Err(e) => return Found::NoProfile(e), + }, + }; + match name { + Some(name) => super::profile_file(&super::profile_path(color_directory().as_deref(), &name)), + None => Found::NoProfile("no color profile is set for this display".into()), + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain([0]).collect() + } + + fn until_nul(w: &[u16]) -> String { + String::from_utf16_lossy(&w[..w.iter().position(|&c| c == 0).unwrap_or(w.len())]) + } + + /// The active display paths. + fn paths() -> Result, String> { + for _ in 0..4 { + let (mut paths_len, mut modes_len) = (0u32, 0u32); + // SAFETY: both pointers are to live `u32`s. + let e = unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, &mut modes_len) }; + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); paths_len as usize]; + let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); modes_len as usize]; + // SAFETY: the arrays hold as many elements as the lengths passed with them. + let e = + unsafe { QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, paths.as_mut_ptr(), &mut modes_len, modes.as_mut_ptr(), None) }; + if e == ERROR_INSUFFICIENT_BUFFER { + // The displays changed between the two calls. + continue; + } + if e != ERROR_SUCCESS { + return Err(format!("Windows couldn't list the displays (error {})", e.0)); + } + paths.truncate(paths_len as usize); + return Ok(paths); + } + Err("the displays kept changing while Windows listed them".into()) + } + + /// The path whose source is the GDI device `device` (`\\.\DISPLAY1`), if one is active. + fn find_path(device: &str) -> Result, String> { + for p in paths()? { + let mut name = DISPLAYCONFIG_SOURCE_DEVICE_NAME { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, + size: size_of::() as u32, + adapterId: p.sourceInfo.adapterId, + id: p.sourceInfo.id, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_SOURCE_DEVICE_NAME` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut name.header) } == 0 && until_nul(&name.viewGdiDeviceName).eq_ignore_ascii_case(device) { + return Ok(Some(Path { source: p.sourceInfo.id, target_adapter: p.targetInfo.adapterId, target: p.targetInfo.id })); + } + } + Ok(None) + } + + /// Whether Windows color-manages the path's display itself: Advanced Color is on (HDR), or + /// wide color is enforced (Automatic Color Management on an SDR display). + fn advanced_color(path: &Path) -> bool { + let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO { + header: DISPLAYCONFIG_DEVICE_INFO_HEADER { + r#type: DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, + size: size_of::() as u32, + adapterId: path.target_adapter, + id: path.target, + }, + ..Default::default() + }; + // SAFETY: the header starts a `DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO` of the size it states. + if unsafe { DisplayConfigGetDeviceInfo(&mut info.header) } != 0 { + return false; + } + // SAFETY: every bit pattern is a valid `u32`. + let bits = unsafe { info.Anonymous.value }; + // advancedColorEnabled is bit 1 and wideColorEnforced bit 2. + bits & 0b110 != 0 + } + + fn api() -> Option<(GetDefault, GetScope)> { + static API: OnceLock> = OnceLock::new(); + *API.get_or_init(|| { + // SAFETY: mscms.dll is loaded from System32 only, and stays loaded for the process. + let module = unsafe { LoadLibraryExW(w!("mscms.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.ok()?; + // SAFETY: the names are NUL-terminated, and the module is loaded. + let (default, scope) = unsafe { + (GetProcAddress(module, s!("ColorProfileGetDisplayDefault"))?, GetProcAddress(module, s!("ColorProfileGetDisplayUserScope"))?) + }; + // SAFETY: both exports have these signatures (icm.h). + unsafe { + Some(( + std::mem::transmute:: isize, GetDefault>(default), + std::mem::transmute:: isize, GetScope>(scope), + )) + } + }) + } + + /// The display's default profile name for its standard color mode, `None` without the API. + fn default_profile(path: &Path) -> Option, String>> { + let (get_default, get_scope) = api()?; + let mut scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + // SAFETY: `scope` is a live `i32`. + if unsafe { get_scope(path.target_adapter, path.source, &mut scope) }.is_err() { + scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0; + } + let mut name = PWSTR::null(); + // SAFETY: `name` is a live pointer the function fills with a string it allocates. + let hr = unsafe { get_default(scope, path.target_adapter, path.source, CPT_ICC.0, CPST_STANDARD_DISPLAY_COLOR_MODE.0, &mut name) }; + if hr.is_err() { + return Some(Err(format!("Windows couldn't give the display's profile (error {:#010x})", hr.0))); + } + if name.is_null() { + return Some(Ok(None)); + } + // SAFETY: `name` is a NUL-terminated string from the call above, freed once with LocalFree. + let text = unsafe { name.to_string() }; + // SAFETY: as above. + let _ = unsafe { LocalFree(Some(HLOCAL(name.0.cast()))) }; + Some(text.map(|t| Some(t).filter(|t| !t.is_empty())).map_err(|e| format!("the display's profile name isn't readable: {e}"))) + } + + /// GDI's profile file for the display device `device`. + fn icm_profile(device: &str) -> Result, String> { + let device = wide(device); + // SAFETY: both strings are NUL-terminated and outlive the call. + let dc = unsafe { CreateDCW(w!("DISPLAY"), PCWSTR(device.as_ptr()), PCWSTR::null(), None) }; + if dc.is_invalid() { + return Err("Windows couldn't open the display".into()); + } + let mut len = 0u32; + // SAFETY: a null buffer asks for the length only. + let _ = unsafe { GetICMProfileW(dc, &mut len, None) }; + let mut buf = vec![0u16; len.clamp(1, 32 * 1024) as usize]; + len = buf.len() as u32; + // SAFETY: `buf` holds `len` characters. + let ok = unsafe { GetICMProfileW(dc, &mut len, Some(PWSTR(buf.as_mut_ptr()))) }.as_bool(); + // SAFETY: `dc` came from CreateDCW above. + let _ = unsafe { DeleteDC(dc) }; + Ok(ok.then(|| until_nul(&buf)).filter(|n| !n.is_empty())) + } + + /// The system's color folder (`…\spool\drivers\color`). + fn color_directory() -> Option { + let mut bytes = 0u32; + // SAFETY: a null buffer asks for the size only, in bytes. + let _ = unsafe { GetColorDirectoryW(PCWSTR::null(), None, &mut bytes) }; + let mut buf = vec![0u16; (bytes as usize / 2).clamp(1, 32 * 1024)]; + bytes = (buf.len() * 2) as u32; + // SAFETY: `buf` holds `bytes` bytes. + unsafe { GetColorDirectoryW(PCWSTR::null(), Some(PWSTR(buf.as_mut_ptr())), &mut bytes) }.as_bool().then(|| PathBuf::from(until_nul(&buf))) + } + + #[cfg(test)] + mod tests { + use windows::Win32::Graphics::Gdi::{DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, EnumDisplayDevicesW}; + + use super::*; + + /// The GDI names (`\\.\DISPLAY1`) of the displays attached to the desktop. + fn attached_displays() -> Vec { + let mut names = Vec::new(); + for i in 0.. { + let mut device = DISPLAY_DEVICEW { cb: size_of::() as u32, ..Default::default() }; + // SAFETY: `device` is a live `DISPLAY_DEVICEW` with `cb` set to its size. + if !unsafe { EnumDisplayDevicesW(PCWSTR::null(), i, &mut device, 0) }.as_bool() { + break; + } + if device.StateFlags.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) { + names.push(until_nul(&device.DeviceName)); + } + } + names + } + + #[test] + fn windows_reads_each_attached_display_and_refuses_an_unknown_one() { + assert_eq!(read(None), Found::NoProfile("Windows didn't name the window's display".into())); + assert!(color_directory().is_none_or(|dir| dir.is_absolute())); + match find_path(r"\\.\DISPLAY999") { + Ok(path) => { + assert!(path.is_none()); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile("Windows couldn't open the display".into())); + } + // A session without displays (a service): the reading gives Windows' error. + Err(e) => { + eprintln!("the displays couldn't be listed: {e}"); + assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile(e)); + return; + } + } + for name in attached_displays() { + // A display without an active path is read through GDI (`icm_profile`). + let path = find_path(&name).unwrap(); + // winit names the display in GDI's spelling; the match ignores letter case. + let lower = find_path(&name.to_lowercase()).unwrap(); + assert_eq!(lower.map(|p| (p.source, p.target)), path.as_ref().map(|p| (p.source, p.target)), "{name} in lowercase"); + match read(Some(&name)) { + Found::Profile { icc, from } => { + eprintln!("{name}: profile {from}, {} bytes", icc.len()); + assert!(!icc.is_empty() && !from.is_empty()); + assert!(!path.as_ref().is_some_and(advanced_color)); + } + Found::System(e) => { + eprintln!("{name}: system, {e}"); + assert!(path.as_ref().is_some_and(advanced_color)); + } + Found::NoProfile(e) => { + eprintln!("{name}: no profile, {e}"); + assert!(!e.is_empty()); + } + } + } + } + } +} + +#[cfg(target_os = "linux")] +mod x11 { + use std::collections::HashMap; + use std::path::Path; + use std::time::Duration; + + use vectorcraft_ui_egui::display_profile::{Found, MAX_PROFILE_BYTES}; + use x11rb::connection::Connection as _; + use x11rb::protocol::randr; + use x11rb::protocol::xproto::{self, AtomEnum}; + use zbus::blocking::{connection::Builder, proxy::Proxy}; + use zbus::zvariant::OwnedObjectPath; + + const COLORD: &str = "org.freedesktop.ColorManager"; + const COLORD_ROOT: &str = "/org/freedesktop/ColorManager"; + const COLORD_MANAGER: &str = "org.freedesktop.ColorManager"; + const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device"; + const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + + pub(super) fn read(output: Option<&str>) -> Found { + let colord = output.map(|o| colord_profile(o).map(|path| path.map(|path| super::usable(super::profile_file(Path::new(&path)))))); + super::x11_choice(colord, || properties(output)) + } + + /// The file colord assigns the RandR output `output`. + fn colord_profile(output: &str) -> Result, String> { + let connection = Builder::system() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))? + .method_timeout(Duration::from_secs(2)) + .build() + .map_err(|e| format!("couldn't connect to the system bus: {e}"))?; + let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't reach colord: {e}"))?; + let devices: Vec = + manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list the displays: {e}"))?; + for path in devices { + let Ok(device) = Proxy::new(&connection, COLORD, &path, COLORD_DEVICE) else { continue }; + let Ok(metadata) = device.get_property::>("Metadata") else { continue }; + if metadata.get("XRANDR_name").map(String::as_str) != Some(output) { + continue; + } + // `*` asks for the device's default profile. + let Ok(profile_path) = device.call::<_, _, OwnedObjectPath>("GetProfileForQualifiers", &(vec!["*"],)) else { return Ok(None) }; + let profile = + Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't reach colord's profile: {e}"))?; + return Ok(profile.get_property::("Filename").ok().filter(|f| !f.is_empty())); + } + Ok(None) + } + + /// The `_ICC_PROFILE` property of the RandR output `output`, or of the screen when only one + /// output is active. + fn properties(output: Option<&str>) -> Result, String> { + let (conn, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?; + let root = conn.setup().roots.get(screen).ok_or_else(|| "the X11 display has no screen".to_string())?.root; + let atom = xproto::intern_atom(&conn, true, b"_ICC_PROFILE") + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .reply() + .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))? + .atom; + if atom == 0 { + return Ok(None); + } + let resources = randr::get_screen_resources_current(&conn, root) + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))? + .reply() + .map_err(|e| format!("couldn't list the X11 outputs: {e}"))?; + let (mut active, mut shown) = (0usize, None); + for o in resources.outputs { + let Ok(Ok(info)) = randr::get_output_info(&conn, o, resources.config_timestamp).map(|c| c.reply()) else { continue }; + if info.connection != randr::Connection::CONNECTED || info.crtc == 0 { + continue; + } + active += 1; + if output.is_some_and(|name| info.name == name.as_bytes()) { + shown = Some(o); + } + } + let longs = (MAX_PROFILE_BYTES / 4) as u32; + let too_large = || Found::NoProfile(format!("the display's profile is larger than {} MiB", MAX_PROFILE_BYTES >> 20)); + if let Some(o) = shown + && let Ok(Ok(p)) = randr::get_output_property(&conn, o, atom, AtomEnum::ANY, 0, longs, false, false).map(|c| c.reply()) + && !p.data.is_empty() + { + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + return Ok(Some(Found::Profile { icc: p.data, from: "the X11 output's _ICC_PROFILE".into() })); + } + if active != 1 { + return Ok(None); + } + let Ok(Ok(p)) = xproto::get_property(&conn, false, root, atom, AtomEnum::ANY, 0, longs).map(|c| c.reply()) else { return Ok(None) }; + if p.value.is_empty() { + return Ok(None); + } + if p.bytes_after > 0 { + return Ok(Some(too_large())); + } + Ok(Some(Found::Profile { icc: p.value, from: "the X11 screen's _ICC_PROFILE".into() })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reading_is_due_first_after_ten_seconds_and_when_the_window_changes_display() { + assert!(due(None, 0.0, None)); + let last = (100.0, Some("DP-1".to_string())); + assert!(!due(Some(&last), 100.0, Some("DP-1"))); + assert!(!due(Some(&last), 109.9, Some("DP-1"))); + assert!(due(Some(&last), 110.0, Some("DP-1"))); + assert!(due(Some(&last), 101.0, Some("HDMI-1"))); + assert!(due(Some(&last), 101.0, None)); + // A clock that went back. + assert!(due(Some(&last), 50.0, Some("DP-1"))); + } + + #[test] + fn wayland_sessions_are_told_apart_from_x11_by_the_environment() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |k: &str| pairs.iter().find(|(n, _)| *n == k).map(|(_, v)| std::ffi::OsString::from(v)) + }; + assert!(wayland_session(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]))); + assert!(wayland_session(env(&[("WAYLAND_SOCKET", "5")]))); + assert!(!wayland_session(env(&[("DISPLAY", ":0")]))); + assert!(!wayland_session(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":0")]))); + } + + #[test] + fn profile_files_larger_than_the_limit_are_refused_before_they_are_read() { + let dir = tempfile::tempdir().unwrap(); + let small = dir.path().join("small.icc"); + std::fs::write(&small, [1u8, 2, 3]).unwrap(); + assert_eq!(read_icc(&small).unwrap(), [1, 2, 3]); + let big = dir.path().join("big.icc"); + let f = std::fs::File::create(&big).unwrap(); + f.set_len(vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES as u64 + 1).unwrap(); + assert!(read_icc(&big).unwrap_err().contains("larger than 1 MiB")); + assert!(read_icc(&dir.path().join("missing.icc")).unwrap_err().starts_with("couldn't open")); + } + + #[test] + fn x11_takes_the_output_property_when_colord_s_file_cannot_be_read() { + let icc = |b: u8| Found::Profile { icc: vec![b], from: "test".into() }; + let property = || Ok(Some(icc(2))); + // colord's profile, read: the property is not asked for. + assert_eq!(x11_choice(Some(Ok(Some(icc(1)))), || panic!("not asked")), icc(1)); + // colord names a file that can't be read, or has no profile, or isn't running. + assert_eq!(x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), property), icc(2)); + assert_eq!(x11_choice(Some(Ok(None)), property), icc(2)); + assert_eq!(x11_choice(Some(Err("no colord".into())), property), icc(2)); + assert_eq!(x11_choice(None, property), icc(2)); + // Neither has a profile: colord's reason is kept. + assert_eq!( + x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), || Ok(None)), + Found::NoProfile("no profile is set for this display (couldn't open a.icc)".into()) + ); + assert_eq!(x11_choice(None, || Ok(None)), Found::NoProfile("no profile is set for this display".into())); + assert_eq!(x11_choice(None, || Err("no X11".into())), Found::NoProfile("no X11".into())); + // colord's file holds no usable profile: the property is used. + let broken = usable(Found::Profile { icc: vec![1, 2, 3], from: "a.icc".into() }); + assert_eq!(broken, Found::NoProfile("a.icc: the file is not an ICC profile".into())); + assert_eq!(x11_choice(Some(Ok(Some(broken))), property), icc(2)); + assert_eq!(usable(Found::NoProfile("none".into())), Found::NoProfile("none".into())); + } + + #[test] + fn a_profile_name_is_found_in_the_color_folder_and_a_path_is_kept() { + let dir = std::path::Path::new("C:/Windows/System32/spool/drivers/color"); + assert_eq!(profile_path(Some(dir), "Display.icm"), dir.join("Display.icm")); + assert_eq!(profile_path(Some(dir), "D:\\profiles\\Display.icm"), std::path::PathBuf::from("D:\\profiles\\Display.icm")); + assert_eq!(profile_path(None, "Display.icm"), std::path::PathBuf::from("Display.icm")); + } +} diff --git a/apps/vectorcraft/src/linux_screen_color.rs b/apps/vectorcraft/src/linux_screen_color.rs index 0f46af19d8..cfb8d7d69f 100644 --- a/apps/vectorcraft/src/linux_screen_color.rs +++ b/apps/vectorcraft/src/linux_screen_color.rs @@ -4,7 +4,6 @@ //! parent is the window, named on that thread as for the portal's save dialog (`portal_save`). mod portal; -mod request; /// The portal's color sampler over the window `parent` holds; `ui` is woken when it answers. pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vectorcraft_ui_egui::screen_color::StartSampler { diff --git a/apps/vectorcraft/src/linux_screen_color/portal.rs b/apps/vectorcraft/src/linux_screen_color/portal.rs index b7518e1ce3..21bcb34da5 100644 --- a/apps/vectorcraft/src/linux_screen_color/portal.rs +++ b/apps/vectorcraft/src/linux_screen_color/portal.rs @@ -61,9 +61,9 @@ pub(super) fn connect(builder: zbus::Result Result, Failure> { const DESKTOP: &str = "org.freedesktop.portal.Desktop"; - let token = super::request::token("vectorcraft_color").map_err(Failure::Failed)?; + let token = crate::portal_request::token("vectorcraft_color").map_err(Failure::Failed)?; // Made before the call: a portal can send its answer before the call returns. - let replies = super::request::Responses::new(conn).map_err(Failure::Failed)?; + let replies = crate::portal_request::Responses::new(conn).map_err(Failure::Failed)?; let proxy = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot") .map_err(|e| Failure::Failed(e.to_string()))?; let options = HashMap::from([("handle_token", Value::from(token.as_str()))]); @@ -106,6 +106,7 @@ fn answer(code: u32, results: &HashMap) -> Result HashMap { HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())]) @@ -186,35 +187,6 @@ mod tests { } } - /// A session bus of its own (`dbus-daemon`), `None` when there is none to run. - fn private_bus() -> Option<(Daemon, String)> { - use std::io::BufRead as _; - let spawning = crate::spawning(); - let daemon = std::process::Command::new("dbus-daemon") - .args(["--session", "--nofork", "--print-address=1"]) - .stdout(std::process::Stdio::piped()) - .spawn(); - drop(spawning); - let Ok(daemon) = daemon else { - eprintln!("no dbus-daemon: skipped"); - return None; - }; - let mut daemon = Daemon(daemon); - let mut address = String::new(); - std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap(); - Some((daemon, address.trim().to_string())) - } - - /// Stops the private bus when the test ends, unwinding included. - struct Daemon(std::process::Child); - - impl Drop for Daemon { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } - } - /// The client's connection to the private bus at `address`, made as the sampler makes its own. fn client(address: &str) -> Connection { connect(zbus::blocking::connection::Builder::address(address)).unwrap() diff --git a/apps/vectorcraft/src/linux_screen_color/request.rs b/apps/vectorcraft/src/linux_screen_color/request.rs deleted file mode 100644 index f442b840a3..0000000000 --- a/apps/vectorcraft/src/linux_screen_color/request.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! The answer to the color sampler's XDG desktop portal request -//! (). -//! The wait ends with the request's `Response`, or with an error when the portal leaves the bus -//! first. - -use std::io::Read as _; - -use futures_lite::{StreamExt as _, future}; -use zbus::blocking::{Connection, MessageIterator}; -use zbus::zvariant::OwnedObjectPath; - -const DESKTOP: &str = "org.freedesktop.portal.Desktop"; - -/// A request token: `prefix`, then 128 bits from the operating system's random device, so the -/// token is unique and can't be guessed, as the Request documentation asks. -pub(super) fn token(prefix: &str) -> Result { - let mut bytes = [0; 16]; - std::fs::File::open("/dev/urandom") - .and_then(|mut file| file.read_exact(&mut bytes)) - .map_err(|e| format!("could not create a portal request token: {e}"))?; - let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); - Ok(format!("{prefix}_{random}")) -} - -/// Subscriptions made before a portal method call, on every request path: a portal older than -/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer -/// can come before the call's reply. Each stream keeps every signal from the time it is made. -pub(super) struct Responses { - replies: MessageIterator, - owners: MessageIterator, -} - -impl Responses { - pub(super) fn new(conn: &Connection) -> Result { - let reply_rule = zbus::MatchRule::builder() - .msg_type(zbus::message::Type::Signal) - .sender(DESKTOP) - .and_then(|b| b.interface("org.freedesktop.portal.Request")) - .and_then(|b| b.member("Response")) - .map_err(|e| e.to_string())? - .build(); - let owner_rule = zbus::MatchRule::builder() - .msg_type(zbus::message::Type::Signal) - .sender("org.freedesktop.DBus") - .and_then(|b| b.path("/org/freedesktop/DBus")) - .and_then(|b| b.interface("org.freedesktop.DBus")) - .and_then(|b| b.member("NameOwnerChanged")) - .and_then(|b| b.arg(0, DESKTOP)) - .map_err(|e| e.to_string())? - .build(); - Ok(Self { - replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?, - owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?, - }) - } - - /// The `Response` on the request path `call` returned, from the connection that returned it. - /// An error when that connection leaves the bus first, or the bus connection closes. - pub(super) fn wait(self, call: &zbus::Message) -> Result { - enum Event { - Reply(Option>), - Owner(Option>), - } - let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; - let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?; - let mut replies = self.replies.into_inner(); - let mut owners = self.owners.into_inner(); - future::block_on(async { - loop { - let event = future::or(async { Event::Reply(replies.next().await) }, async { Event::Owner(owners.next().await) }).await; - match event { - Event::Reply(reply) => { - let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?; - let header = reply.header(); - if header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str()) - { - return Ok(reply); - } - } - Event::Owner(message) => { - let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?; - if owner_disappeared(&message, &portal)? { - return Err("the desktop portal disappeared while waiting for its answer".into()); - } - } - } - } - }) - } -} - -/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer -/// owns the portal's name. The bus itself must send it: another connection can't end the wait. -fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result { - if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") { - return Ok(false); - } - let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?; - Ok(name == DESKTOP && old == portal && new != portal) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_the_bus_can_report_portal_owner_loss() { - let changed = |sender, name, old, new| { - zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged") - .unwrap() - .sender(sender) - .unwrap() - .build(&(name, old, new)) - .unwrap() - }; - let forged = changed(":1.42", DESKTOP, ":1.7", ""); - assert!(!owner_disappeared(&forged, ":1.7").unwrap()); - let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", ""); - assert!(owner_disappeared(&real, ":1.7").unwrap()); - let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", ""); - assert!(!owner_disappeared(&other, ":1.7").unwrap()); - let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", ""); - assert!(!owner_disappeared(&old, ":1.7").unwrap()); - let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7"); - assert!(!owner_disappeared(&acquired, ":1.7").unwrap()); - } -} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 33e962c0aa..7594bc2344 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -28,6 +28,7 @@ mod clipboard; mod compositor_gpu; mod control_server; mod cursor_theme; +mod display_profile; #[cfg(feature = "wgpu")] mod gpu; mod hang_watch; @@ -53,6 +54,8 @@ mod mac_tablet; mod mac_window; #[cfg(target_os = "macos")] mod open_documents; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +mod portal_request; #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))] mod portal_save; mod prefs_dir; @@ -129,6 +132,8 @@ struct App { held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// Reads the profile of the display that shows the main window, for the canvas. + display_profile: display_profile::Reader, /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). frame_start: Option, } @@ -632,6 +637,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + display_profile: Default::default(), frame_start: None, })) }), diff --git a/apps/vectorcraft/src/portal_request.rs b/apps/vectorcraft/src/portal_request.rs new file mode 100644 index 0000000000..da4cb9ad23 --- /dev/null +++ b/apps/vectorcraft/src/portal_request.rs @@ -0,0 +1,231 @@ +//! Linux and the BSDs: the answer to an XDG desktop portal request +//! (), +//! for the save dialog (`portal_save`) and, on Linux, the screen color sampler +//! (`linux_screen_color`). The wait ends with the request's `Response`, or with an error when the +//! portal leaves the bus first. + +use futures_lite::{StreamExt as _, future}; +use zbus::blocking::{Connection, MessageIterator}; +use zbus::zvariant::OwnedObjectPath; + +const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + +/// A request token: `prefix`, then 128 bits from the operating system's random device, so the +/// token is unique and can't be guessed, as the Request documentation asks. +#[cfg(target_os = "linux")] +pub(crate) fn token(prefix: &str) -> Result { + use std::io::Read as _; + + let mut bytes = [0; 16]; + std::fs::File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .map_err(|e| format!("could not create a portal request token: {e}"))?; + let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(format!("{prefix}_{random}")) +} + +/// Subscriptions made before a portal method call, on every request path: a portal older than +/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer +/// can come before the call's reply. Each stream keeps every signal from the time it is made. +pub(crate) struct Responses { + replies: MessageIterator, + owners: MessageIterator, +} + +impl Responses { + pub(crate) fn new(conn: &Connection) -> Result { + let reply_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DESKTOP) + .and_then(|b| b.interface("org.freedesktop.portal.Request")) + .and_then(|b| b.member("Response")) + .map_err(|e| e.to_string())? + .build(); + let owner_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus") + .and_then(|b| b.path("/org/freedesktop/DBus")) + .and_then(|b| b.interface("org.freedesktop.DBus")) + .and_then(|b| b.member("NameOwnerChanged")) + .and_then(|b| b.arg(0, DESKTOP)) + .map_err(|e| e.to_string())? + .build(); + Ok(Self { + replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?, + owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?, + }) + } + + /// The `Response` on the request path `call` returned, from the connection that returned it. + /// An error when that connection leaves the bus first, or the bus connection closes. A + /// `Response` the portal sent before it left is still the answer. + pub(crate) fn wait(self, call: &zbus::Message) -> Result { + enum Event { + Reply(Option>), + Owner(Option>), + } + let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; + let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?; + let answers = |reply: &zbus::Message| { + let header = reply.header(); + header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str()) + }; + let mut replies = self.replies.into_inner(); + let mut owners = self.owners.into_inner(); + future::block_on(async { + loop { + let event = future::or(async { Event::Owner(owners.next().await) }, async { Event::Reply(replies.next().await) }).await; + match event { + Event::Reply(reply) => { + let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?; + if answers(&reply) { + return Ok(reply); + } + } + Event::Owner(message) => { + let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?; + if owner_disappeared(&message, &portal)? { + // zbus puts each message in every matching stream before it reads the + // next one, so a `Response` that came before the bus's report is + // already queued: it is still the answer. + while let Some(Some(reply)) = future::poll_once(replies.next()).await { + if let Ok(reply) = reply + && answers(&reply) + { + return Ok(reply); + } + } + return Err("the desktop portal disappeared while waiting for its answer".into()); + } + } + } + } + }) + } +} + +/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer +/// owns the portal's name. The bus itself must send it: another connection can't end the wait. +fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result { + if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") { + return Ok(false); + } + let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?; + Ok(name == DESKTOP && old == portal && new != portal) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_bus_can_report_portal_owner_loss() { + let changed = |sender, name, old, new| { + zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged") + .unwrap() + .sender(sender) + .unwrap() + .build(&(name, old, new)) + .unwrap() + }; + let forged = changed(":1.42", DESKTOP, ":1.7", ""); + assert!(!owner_disappeared(&forged, ":1.7").unwrap()); + let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", ""); + assert!(owner_disappeared(&real, ":1.7").unwrap()); + let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", ""); + assert!(!owner_disappeared(&other, ":1.7").unwrap()); + let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", ""); + assert!(!owner_disappeared(&old, ":1.7").unwrap()); + let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7"); + assert!(!owner_disappeared(&acquired, ":1.7").unwrap()); + } + + /// Through a real D-Bus: when the bus has reported the portal gone, a `Response` the portal sent + /// before it left is still the answer; without one the wait ends with an error. + #[test] + fn a_queued_answer_wins_over_the_portal_leaving() { + use std::collections::HashMap; + + use zbus::blocking::connection::Builder; + use zbus::zvariant::OwnedValue; + + for answered in [true, false] { + let Some((_daemon, address)) = test_bus::private_bus() else { return }; + let address = address.as_str(); + let portal = Builder::address(address).unwrap().name(DESKTOP).unwrap().build().unwrap(); + let unique = portal.unique_name().unwrap().to_string(); + let client = Builder::address(address).unwrap().build().unwrap(); + let responses = Responses::new(&client).unwrap(); + // zbus puts each message in every matching stream before it reads the next one + // (`src/connection/socket_reader.rs`), so once this stream has the bus's report, + // `responses` holds the `Response` sent before it. + let gone_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus") + .unwrap() + .member("NameOwnerChanged") + .unwrap() + .arg(0, DESKTOP) + .unwrap() + .build(); + let gone = MessageIterator::for_match_rule(gone_rule, &client, None).unwrap(); + let path = "/org/freedesktop/portal/desktop/request/test/t1"; + if answered { + let results = HashMap::::new(); + portal.emit_signal(None::<&str>, path, "org.freedesktop.portal.Request", "Response", &(0_u32, results)).unwrap(); + } + portal.close().unwrap(); + // On a thread of its own, so a report that never comes fails the test instead of + // hanging it. + let (seen, report) = std::sync::mpsc::channel(); + let left = unique.clone(); + std::thread::spawn(move || { + let mut gone = gone; + let _ = seen.send(gone.any(|m| m.is_ok_and(|m| owner_disappeared(&m, &left).unwrap_or(false)))); + }); + assert_eq!(report.recv_timeout(std::time::Duration::from_secs(30)), Ok(true), "the bus did not report the portal gone"); + // The call's reply as `wait` reads it: the request path, from the portal's connection. + let call = zbus::Message::signal("/", "org.example.Test", "Reply") + .unwrap() + .sender(unique.as_str()) + .unwrap() + .build(&OwnedObjectPath::try_from(path).unwrap()) + .unwrap(); + let waited = responses.wait(&call); + assert_eq!(waited.is_ok(), answered, "{waited:?}"); + } + } +} + +/// The private session bus that the D-Bus tests of the portal requests run on. +#[cfg(test)] +pub(crate) mod test_bus { + /// A session bus of its own (`dbus-daemon`), `None` when there is none to run. + pub(crate) fn private_bus() -> Option<(Daemon, String)> { + use std::io::BufRead as _; + let spawning = crate::spawning(); + let daemon = std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn(); + drop(spawning); + let Ok(daemon) = daemon else { + eprintln!("no dbus-daemon: skipped"); + return None; + }; + let mut daemon = Daemon(daemon); + let mut address = String::new(); + std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap(); + Some((daemon, address.trim().to_string())) + } + + /// Stops the private bus when the test ends, unwinding included. + pub(crate) struct Daemon(std::process::Child); + + impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } +} diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index a9bf5d1009..6d1a8734b2 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -8,6 +8,7 @@ //! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension. //! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its //! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call. +//! rfd also shows the dialog when the portal leaves the bus before it answers. //! //! The dialog's parent is the window: `x11:` on X11, and on Wayland (Linux) a handle //! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`). @@ -183,37 +184,18 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option Result, String> { use std::sync::atomic::{AtomicU32, Ordering}; - use zbus::blocking::{MessageIterator, Proxy}; - use zbus::zvariant::OwnedObjectPath; + use zbus::blocking::Proxy; const DESKTOP: &str = "org.freedesktop.portal.Desktop"; - const REQUEST: &str = "org.freedesktop.portal.Request"; static NEXT: AtomicU32 = AtomicU32::new(0); let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)); - // Listen before asking, on every request path: a portal older than version 0.9 ignores - // `handle_token` and answers on the request path it returns, and its answer can come before - // the call's reply. The stream keeps every `Response` from the time it is made. - let rule = zbus::MatchRule::builder() - .msg_type(zbus::message::Type::Signal) - .sender(DESKTOP) - .and_then(|b| b.interface(REQUEST)) - .and_then(|b| b.member("Response")) - .map_err(|e| e.to_string())? - .build(); - let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?; + // Made before the call: a portal can send its answer before the call returns, and a portal + // that leaves the bus while its dialog is open ends the wait. + let responses = crate::portal_request::Responses::new(conn)?; let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?; // The title is empty, as rfd sends it for a save. let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?; - let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; - // The answer is the `Response` on the path returned, from the connection that returned it. - let portal = call.header().sender().map(|s| s.as_str().to_string()); - let reply = responses - .filter_map(Result::ok) - .find(|m| { - let header = m.header(); - header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref() - }) - .ok_or("the save dialog closed without an answer")?; + let reply = responses.wait(&call)?; let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; answer(code, &results) } @@ -495,26 +477,22 @@ mod tests { Old, /// As `Old`, with the `Response` sent before the reply that names its path. OldEarly, + /// As `Current`, but it leaves the bus 300 ms after its reply without a `Response`, as a + /// portal that exits while its dialog is open. + Exits, + /// Leaves the bus right after its reply, without a `Response`. + ExitsAtOnce, + /// Sends its `Response` right after its reply, then leaves the bus. + AnswersAndExits, } /// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` /// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it. /// `None` when there is no `dbus-daemon` to run. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] - fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { - use std::io::BufRead as _; - + fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, crate::portal_request::test_bus::Daemon)> { use zbus::zvariant::OwnedObjectPath; - let spawning = crate::spawning(); - let daemon = std::process::Command::new("dbus-daemon") - .args(["--session", "--nofork", "--print-address=1"]) - .stdout(std::process::Stdio::piped()) - .spawn(); - drop(spawning); - let mut daemon = daemon.ok()?; - let mut address = String::new(); - std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; - let address = address.trim().to_string(); + let (daemon, address) = crate::portal_request::test_bus::private_bus()?; let server = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?; let seen = Seen::default(); @@ -539,8 +517,8 @@ mod tests { }); let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break }; let token = match portal { - Portal::Current => text("handle_token").unwrap_or_default(), Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()), + Portal::Current | Portal::Exits | Portal::ExitsAtOnce | Portal::AnswersAndExits => text("handle_token").unwrap_or_default(), }; let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break }; @@ -558,14 +536,22 @@ mod tests { if server.reply(&header, &handle).is_err() { break; } - if portal == Portal::Old { + if matches!(portal, Portal::Old | Portal::Exits) { // As a person answers the dialog: after the caller has the path returned. std::thread::sleep(std::time::Duration::from_millis(300)); } + if matches!(portal, Portal::Exits | Portal::ExitsAtOnce) { + break; + } if portal != Portal::OldEarly && respond().is_err() { break; } + if portal == Portal::AnswersAndExits { + break; + } } + // The portal leaves the bus: the bus sends `NameOwnerChanged` for its name. + let _ = server.close(); }); let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?; Some((client, seen, daemon)) @@ -589,7 +575,7 @@ mod tests { #[test] fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() { let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)]; - let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else { + let Some((conn, seen, _daemon)) = mock_portal(script, Portal::Current) else { eprintln!("no dbus-daemon: skipped"); return; }; @@ -599,7 +585,6 @@ mod tests { assert_eq!(save_within(&conn, &request), Ok(None)); let seen = seen.lock().map(|s| s.clone()).unwrap_or_default(); assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone())))); - let _ = daemon.kill(); } /// A portal older than 0.9 answers on the request path it returns, and the answer is awaited @@ -607,13 +592,12 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] #[test] fn the_answer_is_awaited_on_the_path_the_portal_returns() { - let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else { + let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else { eprintln!("no dbus-daemon: skipped"); return; }; let request = request(); assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); - let _ = daemon.kill(); } /// A portal older than 0.9 can answer before its reply names the request path: the answer is @@ -622,14 +606,41 @@ mod tests { #[test] fn an_answer_that_comes_before_the_path_is_kept() { let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)]; - let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else { + let Some((conn, _seen, _daemon)) = mock_portal(script, Portal::OldEarly) else { eprintln!("no dbus-daemon: skipped"); return; }; let request = request(); assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); assert_eq!(save_within(&conn, &request), Ok(None)); - let _ = daemon.kill(); + } + + /// A portal that leaves the bus after its reply without answering, while its dialog is open or + /// at once, ends the wait with an error, and rfd shows the dialog. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn a_portal_that_exits_during_the_dialog_ends_the_wait() { + for portal in [Portal::Exits, Portal::ExitsAtOnce] { + let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], portal) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_within(&conn, &request()), Err("the desktop portal disappeared while waiting for its answer".to_string())); + } + } + + /// A portal that answers and then leaves the bus: the save reads the answer. Whether the answer + /// or the bus's report comes first depends on timing here; `portal_request`'s + /// `a_queued_answer_wins_over_the_portal_leaving` queues both before the wait. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn an_answer_sent_just_before_the_portal_exits_is_kept() { + let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::AnswersAndExits) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let request = request(); + assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); } /// The labels go to the dialog in the interface's language, which is how the answer is matched. diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index f25615c589..080462d0b6 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -762,9 +762,40 @@ pub fn library(doc: &Document) -> Vec { } /// Parse a stored library value (malformed entries are skipped, values kept within their -/// ranges; see [`Brush::from_json`]). +/// ranges; see [`Brush::from_json`]). A name that repeats is kept by its first brush, and each +/// later brush of that name is numbered ([`number_repeated_names`]). pub fn parse_library(v: &serde_json::Value) -> Vec { - v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default() + let mut lib: Vec = v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default(); + number_repeated_names(&mut lib); + lib +} + +/// Renames the second and later brushes of each repeated name in `lib` to the first "Name 2", +/// "Name 3"… that no brush in `lib` has. The first brush of a name keeps it, so a stroke that names +/// it draws the brush [`find`] gave it before. +fn number_repeated_names(lib: &mut [Brush]) { + let mut seen = std::collections::HashSet::new(); + if lib.iter().all(|b| seen.insert(b.name.as_str())) { + return; + } + let mut taken: std::collections::HashSet = lib.iter().map(|b| b.name.clone()).collect(); + let mut kept = std::collections::HashSet::new(); + // The next number to try for each repeated name, so the brushes of one name try each number + // once between them. + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + for b in lib.iter_mut() { + if kept.insert(b.name.clone()) { + continue; + } + let from = next.entry(b.name.clone()).or_insert(2); + // `taken` is finite, so one of the numbers is free. + if let Some((i, name)) = (*from..).map(|i| (i, format!("{} {i}", b.name))).find(|(_, n)| !taken.contains(n)) { + *from = i.saturating_add(1); + taken.insert(name.clone()); + kept.insert(name.clone()); + b.name = name; + } + } } /// Store `lib` as the document's brush library. diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index affd2fa6b7..2d5156d162 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -945,6 +945,34 @@ fn library_defaults_store_and_find() { assert_eq!(unique_name(&lib, "Arrow"), "Arrow 2"); } +/// A stored library that repeats a name reads with the first brush of that name as stored and each +/// later one numbered, past every name the library already has; strokes of the name keep drawing +/// the first brush. +#[test] +fn repeated_brush_names_are_numbered_when_read() { + let mut d = Document::new(100.0, 100.0); + let named = |name: &str, size: f64| Brush { name: name.into(), ..calli(0.0, 100.0, size) }; + store(&mut d, &[named("Nib", 1.0), named("Nib 2", 2.0), named("Nib", 3.0), named("Other", 4.0), named("Nib", 5.0), named("Nib 2", 6.0)]); + let lib = library(&d); + let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["Nib", "Nib 2", "Nib 3", "Other", "Nib 4", "Nib 2 2"]); + let size = |b: &Brush| if let BrushKind::Calligraphic(c) = &b.kind { c.size } else { 0.0 }; + assert_eq!(lib.iter().map(size).collect::>(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert_eq!(find(&d, "Nib").map(|b| size(&b)), Some(1.0)); + assert_eq!(find(&d, "Nib 3").map(|b| size(&b)), Some(3.0)); + // Reading again gives the same names, and the numbered library stored again reads as stored. + assert_eq!(library(&d), lib); + store(&mut d, &lib); + assert_eq!(library(&d), lib); + // Many brushes of one name take the numbers in order, past the numbered names the file has. + let many: Vec = (0..5000).map(|i| named(if i == 1 { "Nib 3" } else { "Nib" }, 1.0)).collect(); + store(&mut d, &many); + let names: Vec = library(&d).into_iter().map(|b| b.name).collect(); + assert_eq!(names[..5], ["Nib", "Nib 3", "Nib 2", "Nib 4", "Nib 5"]); + assert_eq!(names.last().map(String::as_str), Some("Nib 5000")); + assert_eq!(names.iter().collect::>().len(), names.len()); +} + #[test] fn brushes_serde_round_trip() { let lib = defaults().to_vec(); diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 46dcdb52ba..f4297343a6 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -2,7 +2,8 @@ //! gamut checks and soft-proof transforms. //! //! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB -//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. +//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB; +//! the desktop canvas converts it into the display's own profile last ([`display`]). //! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model //! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers); //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). @@ -10,6 +11,7 @@ //! the profiles [`use_document_profiles`] last put in place of the working spaces; //! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`]. +pub mod display; pub mod generic; pub mod icc; pub mod lab; @@ -531,7 +533,7 @@ impl Cms { } } - /// Gamut warning: can this colour be printed in the working CMYK space? + /// Gamut warning: can this color be printed in this context's CMYK space? pub fn out_of_gamut(&self, c: &Color) -> bool { self.gamut_error(c) > GAMUT_THRESHOLD } diff --git a/crates/color/src/cms/display.rs b/crates/color/src/cms/display.rs new file mode 100644 index 0000000000..03fe00220c --- /dev/null +++ b/crates/color/src/cms/display.rs @@ -0,0 +1,327 @@ +//! The canvas's last step before the screen: the sRGB the canvas draws, converted into a display's +//! ICC profile (Preferences › Performance › Use Monitor Profile for Canvas). +//! +//! The display profile is a destination only. Document colors, proofs, exports and the web build +//! keep their sRGB output; a soft proof is applied first, then this conversion. +//! +//! [`DisplayTransform::from_icc`] accepts an RGB display-class (`mntr`) profile of at most +//! [`MAX_PROFILE_BYTES`] whose declared length fits the data and from which a transform can be built. +//! The conversion is relative colorimetric, sampled once into a [`LUT_N`]³ table, so drawing a frame +//! costs a table lookup per pixel and never builds a transform. + +use std::hash::{Hash, Hasher}; + +use super::{Intent, ProfileKind, icc::IccProfile}; + +/// The largest display profile read: VectorCraft's own limit. Display profiles are a few kilobytes +/// (matrix/shaper) to a few hundred (table-based). +pub const MAX_PROFILE_BYTES: usize = 1 << 20; + +/// Grid points per channel of the conversion table. +const LUT_N: usize = 33; + +/// Why a display profile can't be used. The canvas then stays sRGB. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProfileError { + /// More bytes than [`MAX_PROFILE_BYTES`]. + TooLarge(usize), + /// No ICC header (`acsp` signature) or a header cut short. + NotIcc, + /// The header's length is shorter than a header or longer than the data. + BadLength { declared: usize, len: usize }, + /// The profile class isn't a display's (`mntr`): an input, output or other profile. + NotDisplay(String), + /// The profile's data color space isn't RGB. + NotRgb(String), + /// The profile doesn't parse. + Unreadable(String), + /// No conversion from sRGB into the profile can be built. + NoTransform, +} + +impl std::fmt::Display for ProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooLarge(n) => write!(f, "the profile is {n} bytes, more than the {MAX_PROFILE_BYTES} a display profile may have"), + Self::NotIcc => f.write_str("the file is not an ICC profile"), + Self::BadLength { declared, len } => write!(f, "the profile's header gives a length of {declared} bytes for {len} bytes of data"), + Self::NotDisplay(class) => write!(f, "the profile's class is `{class}`, not a display profile (`mntr`)"), + Self::NotRgb(space) => write!(f, "the profile's color space is `{space}`, not RGB"), + Self::Unreadable(e) => write!(f, "the profile can't be read: {e}"), + Self::NoTransform => f.write_str("no conversion into the profile can be built"), + } + } +} + +/// Four header bytes as text, for an error message. +fn signature(b: &[u8]) -> String { + String::from_utf8_lossy(b).trim_end().to_string() +} + +/// sRGB → a display profile, as a table. +pub struct DisplayTransform { + name: String, + fingerprint: u64, + /// `LUT_N`³ display values, red slowest; `None` when the profile shows sRGB unchanged (every + /// grid point within half an 8-bit step of itself). + table: Option>, +} + +impl std::fmt::Debug for DisplayTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DisplayTransform").field("name", &self.name).field("identity", &self.is_identity()).finish() + } +} + +impl DisplayTransform { + /// Checks `bytes` as a display profile, as [`Self::from_icc`] does, without building the + /// conversion table. + pub fn check(bytes: &[u8]) -> Result<(), ProfileError> { + Self::parse(bytes).map(|_| ()) + } + + /// Checks `bytes` as a display profile and builds the conversion into it. + pub fn from_icc(bytes: &[u8]) -> Result { + let (profile, bytes) = Self::parse(bytes)?; + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + let s = (LUT_N - 1) as f32; + let mut table = Vec::with_capacity(LUT_N.pow(3)); + for r in 0..LUT_N { + for g in 0..LUT_N { + for b in 0..LUT_N { + let rgb = [r as f32 / s, g as f32 / s, b as f32 / s]; + table.push(to_display(rgb).ok_or(ProfileError::NoTransform)?); + } + } + } + let grid = |i: usize| [i / (LUT_N * LUT_N), i / LUT_N % LUT_N, i % LUT_N].map(|v| v as f32 / s); + let identity = table.iter().enumerate().all(|(i, v)| v.iter().zip(grid(i)).all(|(a, b)| (a - b).abs() <= 0.5 / 255.0)); + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + Ok(Self { name: profile.name.clone(), fingerprint: h.finish(), table: (!identity).then_some(table) }) + } + + /// The parsed profile and its bytes up to its declared length, when `bytes` is an RGB display + /// profile from which a conversion can be built. + fn parse(bytes: &[u8]) -> Result<(IccProfile, &[u8]), ProfileError> { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(ProfileError::TooLarge(bytes.len())); + } + if bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") { + return Err(ProfileError::NotIcc); + } + let declared = bytes.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize); + if declared < 132 || declared > bytes.len() { + return Err(ProfileError::BadLength { declared, len: bytes.len() }); + } + let bytes = &bytes[..declared]; + if &bytes[12..16] != b"mntr" { + return Err(ProfileError::NotDisplay(signature(&bytes[12..16]))); + } + if &bytes[16..20] != b"RGB " { + return Err(ProfileError::NotRgb(signature(&bytes[16..20]))); + } + let profile = IccProfile::from_bytes(None, bytes).map_err(|e| ProfileError::Unreadable(e.to_string()))?; + if profile.kind != ProfileKind::Rgb { + return Err(ProfileError::NotRgb(format!("{:?}", profile.kind))); + } + let to_display = + |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?])); + if to_display([0.5; 3]).is_none() { + return Err(ProfileError::NoTransform); + } + Ok((profile, bytes)) + } + + /// The profile's description. + pub fn name(&self) -> &str { + &self.name + } + + /// A hash of the profile's bytes, which tells two profiles with the same name apart. + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + /// The profile shows sRGB unchanged, so nothing is converted. + pub fn is_identity(&self) -> bool { + self.table.is_none() + } + + /// One sRGB color (0..1) in the display profile. + pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + let Some(table) = &self.table else { return rgb.map(|v| v.clamp(0.0, 1.0)) }; + let n = LUT_N; + let s = (n - 1) as f32; + let p = rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) * s }); + let i = p.map(|v| (v.floor() as usize).min(n - 2)); + let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32]; + let mut out = [0.0f32; 3]; + for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] { + for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] { + for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] { + let w = wr * wg * wb; + if w == 0.0 { + continue; + } + let v = table[((i[0] + dr) * n + i[1] + dg) * n + i[2] + db]; + for c in 0..3 { + out[c] += w * v[c]; + } + } + } + } + out.map(|v| v.clamp(0.0, 1.0)) + } + + /// One 8-bit sRGB color in the display profile. + pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] { + if self.table.is_none() { + return rgb; + } + self.apply(rgb.map(|v| f32::from(v) / 255.0)).map(|v| (v * 255.0).round() as u8) + } + + /// Premultiplied RGBA8 pixels, converted in place. Alpha is kept, and fully transparent + /// pixels are left as they are. + pub fn apply_premultiplied(&self, pixels: &mut [u8]) { + if self.table.is_none() { + return; + } + let mut last_in = [0u8; 4]; + let mut last_out = [0u8; 4]; + for px in pixels.as_chunks_mut::<4>().0 { + let a = px[3]; + if a == 0 { + continue; + } + if *px == last_in { + *px = last_out; + continue; + } + last_in = *px; + let un = |c: u8| if a == 255 { c } else { ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }; + let out = self.apply8([un(px[0]), un(px[1]), un(px[2])]); + for (c, o) in px.iter_mut().zip(out) { + *c = if a == 255 { o } else { ((u32::from(o) * u32::from(a) + 127) / 255) as u8 }; + } + last_out = *px; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cms::{DISPLAY_P3, GENERIC_CMYK, SRGB, icc_bytes}; + + /// `name`'s ICC file, marked as a display profile. + fn display(name: &str) -> Vec { + let mut b = icc_bytes(name).unwrap().to_vec(); + b[12..16].copy_from_slice(b"mntr"); + b + } + + #[test] + fn a_wide_gamut_display_shows_srgb_red_less_saturated_and_srgb_is_left_alone() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + assert!(!p3.is_identity()); + assert_eq!(p3.name(), DISPLAY_P3); + // sRGB red is inside Display P3: its display values have green and blue above 0 and less red. + let [r, g, b] = p3.apply8([255, 0, 0]); + assert!(r < 245 && g > 40 && b > 10, "{:?}", [r, g, b]); + // White and black stay where they are (relative colorimetric). + assert_eq!(p3.apply8([255; 3]), [255; 3]); + assert_eq!(p3.apply8([0; 3]), [0; 3]); + let srgb = DisplayTransform::from_icc(&display(SRGB)).unwrap(); + assert!(srgb.is_identity()); + assert_eq!(srgb.apply8([255, 0, 0]), [255, 0, 0]); + assert_ne!(srgb.fingerprint(), p3.fingerprint()); + } + + #[test] + fn the_table_matches_the_profile_between_its_grid_points() { + let bytes = display(DISPLAY_P3); + let p3 = DisplayTransform::from_icc(&bytes).unwrap(); + let profile = IccProfile::from_bytes(None, &bytes).unwrap(); + for rgb in [[0.1, 0.7, 0.33], [0.9, 0.05, 0.6], [0.47, 0.47, 0.47], [0.0, 0.2, 1.0]] { + let want = profile.from_srgb(rgb, Intent::RelativeColorimetric).unwrap(); + let got = p3.apply(rgb); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 1.0 / 255.0, "{rgb:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn premultiplied_pixels_keep_their_alpha_and_transparent_ones_stay_empty() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + let mut px = vec![255, 0, 0, 255, 128, 0, 0, 128, 0, 0, 0, 0, 255, 0, 0, 255]; + p3.apply_premultiplied(&mut px); + let opaque = p3.apply8([255, 0, 0]); + assert_eq!(&px[0..4], &[opaque[0], opaque[1], opaque[2], 255]); + assert_eq!(&px[12..16], &px[0..4], "a repeated pixel converts the same"); + assert_eq!(px[7], 128); + // Half-covered red: the same color at half coverage, within rounding. + for c in 0..3 { + assert!((i32::from(px[4 + c]) - i32::from(opaque[c]) / 2).abs() <= 1, "{:?}", &px[4..8]); + } + assert_eq!(&px[8..12], &[0, 0, 0, 0]); + } + + #[test] + fn profiles_that_are_not_rgb_display_profiles_are_refused() { + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert!(matches!(DisplayTransform::from_icc(&cmyk), Err(ProfileError::NotDisplay(_)))); + let mut cmyk_display = cmyk.clone(); + cmyk_display[12..16].copy_from_slice(b"mntr"); + assert!(matches!(DisplayTransform::from_icc(&cmyk_display), Err(ProfileError::NotRgb(s)) if s == "CMYK")); + let mut input = display(DISPLAY_P3); + input[12..16].copy_from_slice(b"scnr"); + assert_eq!(DisplayTransform::from_icc(&input).unwrap_err(), ProfileError::NotDisplay("scnr".into())); + } + + #[test] + fn malformed_short_and_oversized_data_is_refused() { + assert_eq!(DisplayTransform::from_icc(&[]).unwrap_err(), ProfileError::NotIcc); + assert_eq!(DisplayTransform::from_icc(&[0; 132]).unwrap_err(), ProfileError::NotIcc); + let good = display(DISPLAY_P3); + assert_eq!(DisplayTransform::from_icc(&good[..100]).unwrap_err(), ProfileError::NotIcc); + let mut long = good.clone(); + long[0..4].copy_from_slice(&(good.len() as u32 + 1).to_be_bytes()); + assert_eq!(DisplayTransform::from_icc(&long).unwrap_err(), ProfileError::BadLength { declared: good.len() + 1, len: good.len() }); + let mut short = good.clone(); + short[0..4].copy_from_slice(&64u32.to_be_bytes()); + assert!(matches!(DisplayTransform::from_icc(&short), Err(ProfileError::BadLength { declared: 64, .. }))); + // Trailing bytes past the declared length are not part of the profile. + let mut padded = good.clone(); + padded.extend([0xAB; 16]); + let a = DisplayTransform::from_icc(&padded).unwrap(); + assert_eq!(a.fingerprint(), DisplayTransform::from_icc(&good).unwrap().fingerprint()); + let mut garbage_tags = good.clone(); + for b in &mut garbage_tags[128..] { + *b = 0xFF; + } + assert!(matches!(DisplayTransform::from_icc(&garbage_tags), Err(ProfileError::Unreadable(_) | ProfileError::NoTransform))); + let huge = vec![0u8; MAX_PROFILE_BYTES + 1]; + assert_eq!(DisplayTransform::from_icc(&huge).unwrap_err(), ProfileError::TooLarge(MAX_PROFILE_BYTES + 1)); + } + + #[test] + fn check_accepts_and_refuses_what_from_icc_does() { + assert_eq!(DisplayTransform::check(&display(DISPLAY_P3)), Ok(())); + assert_eq!(DisplayTransform::check(&display(SRGB)), Ok(())); + let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec(); + assert_eq!(DisplayTransform::check(&cmyk), Err(DisplayTransform::from_icc(&cmyk).unwrap_err())); + assert_eq!(DisplayTransform::check(&[1, 2, 3]), Err(ProfileError::NotIcc)); + } + + #[test] + fn nan_and_out_of_range_values_stay_in_range() { + let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap(); + for v in p3.apply([f32::NAN, -1.0, 9.0]) { + assert!((0.0..=1.0).contains(&v)); + } + } +} diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index e49478eab7..54cb50c197 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -65,24 +65,62 @@ pub enum GradientMethod { /// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to /// yellow passes through a light blue-grey rather than a dull mid grey. Perceptual, + /// Mixed around the hue wheel, clockwise as the color wheels draw it: the hue angle falls + /// from each stop to the next (red, magenta, blue, cyan, green, yellow, red), and saturation + /// and brightness change evenly ([`hue_mix`]). + HueClockwise, + /// Mixed around the hue wheel the other way: the hue angle rises from each stop to the next + /// (red, yellow, green, cyan, blue, magenta, red). + HueCounterclockwise, } impl GradientMethod { - /// Parse a method name (`classic`, `perceptual`; any case). + /// Every method, in the order the Gradient panel lists them. + pub const ALL: [GradientMethod; 4] = [Self::Classic, Self::Perceptual, Self::HueClockwise, Self::HueCounterclockwise]; + /// Parse a method name (`classic`, `perceptual`, `hue-clockwise`, `hue-counterclockwise`; + /// any case, and the hyphen may be a space or an underscore, or left out). Any other text, + /// one with a separator elsewhere included, is `None`. pub fn parse(s: &str) -> Option { - [Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + let name = s.to_ascii_lowercase(); + Self::ALL.into_iter().find(|m| ["-", " ", "_", ""].iter().any(|sep| m.id().replace('-', sep) == name)) } pub fn label(self) -> &'static str { match self { Self::Classic => "Classic", Self::Perceptual => "Perceptual", + Self::HueClockwise => "Hue Clockwise", + Self::HueCounterclockwise => "Hue Counterclockwise", + } + } + /// The method's name in command params (`classic`, `perceptual`, `hue-clockwise`, + /// `hue-counterclockwise`). + pub fn id(self) -> &'static str { + match self { + Self::Classic => "classic", + Self::Perceptual => "perceptual", + Self::HueClockwise => "hue-clockwise", + Self::HueCounterclockwise => "hue-counterclockwise", } } pub fn is_classic(&self) -> bool { *self == Self::Classic } - /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an - /// RGB colour between the two). + /// Does it mix around the hue wheel (Hue Clockwise or Hue Counterclockwise)? The colors + /// between two stops then are not mixes of the stops' inks. + pub fn is_hue(&self) -> bool { + matches!(self, Self::HueClockwise | Self::HueCounterclockwise) + } + /// The method that mixes `b` to `a` through the colors this one mixes `a` to `b` through: a + /// hue method turned the other way, Classic and Perceptual as they are. + pub fn mirrored(self) -> Self { + match self { + Self::HueClockwise => Self::HueCounterclockwise, + Self::HueCounterclockwise => Self::HueClockwise, + m => m, + } + } + /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), in OKLab (Perceptual: an + /// RGB colour between the two), or around the hue wheel ([`hue_mix`]: an RGB colour). pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color { match self { Self::Classic => Color::lerp(a, b, t), @@ -93,10 +131,68 @@ impl GradientMethod { let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; Color::rgb(c(r), c(g), c(bl)) } + Self::HueClockwise => hue_mix(a, b, t, false), + Self::HueCounterclockwise => hue_mix(a, b, t, true), } } } +/// The brightness (0..1) at or below which a color is black for [`hue_mix`], and the spread +/// between its largest and smallest RGB numbers (saturation × brightness) at or below which it +/// has no hue of its own: under half a color level. +const NO_HUE: f32 = 0.4 / 255.0; + +/// The hue difference (degrees) that moves an RGB number by half a color level in a color whose +/// largest and smallest RGB numbers are 1 apart; a color with a smaller spread takes +/// proportionally more degrees. [`hue_mix`] counts two hues closer than this as one hue. +const SAME_HUE: f32 = 60.0 * 0.5 / 255.0; + +/// `a` and `b` mixed at `t` (0..1) around the hue wheel, as display RGB: hue (degrees), +/// saturation and brightness each change evenly from `a`'s to `b`'s. The hue angle only rises +/// (`rising`) or only falls, by the fixed-direction rules of +/// , and two equal hues stay where they are. +/// Two hues that differ by less than half a color level ([`SAME_HUE`]) count as equal and take the +/// short way, so a color and a tint or shade of it, whose computed hues differ by rounding, do +/// not turn the whole wheel. A color whose RGB numbers are less than half a color level apart +/// ([`NO_HUE`]) has no hue and takes the other color's; black has no saturation either and takes +/// the other color's too. Two colors without a hue mix as grays. A `t` that is not a number +/// gives `a`. +pub fn hue_mix(a: &Color, b: &Color, t: f32, rising: bool) -> Color { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + let ([mut h0, mut s0, v0], [mut h1, mut s1, v1]) = (a.to_hsb(), b.to_hsb()); + let (black0, black1) = (v0 <= NO_HUE, v1 <= NO_HUE); + // The spread between each color's own largest and smallest RGB numbers. + let (spread0, spread1) = (s0 * v0, s1 * v1); + // Black takes the other color's saturation, so it darkens that color rather than graying it. + if black0 && !black1 { + s0 = s1; + } else if black1 && !black0 { + s1 = s0; + } + let (gray0, gray1) = (black0 || spread0 <= NO_HUE, black1 || spread1 <= NO_HUE); + match (gray0, gray1) { + (true, _) => h0 = h1, + (false, true) => h1 = h0, + (false, false) => { + // The short way from the first hue to the second, -180..180. + let short = (h1 - h0 + 180.0).rem_euclid(360.0) - 180.0; + h1 = if short.abs() * spread0.min(spread1) <= SAME_HUE { + h0 + short + } else if rising { + h0 + short.rem_euclid(360.0) + } else { + h0 + short.rem_euclid(360.0) - 360.0 + }; + } + } + let at = |p: f32, q: f32| p + (q - p) * t; + let (h, s, v) = (at(h0, h1), at(s0, s1), at(v0, v1)); + if !(h.is_finite() && s.is_finite() && v.is_finite()) { + return Color::rgb(0.0, 0.0, 0.0); + } + Color::from_hsb(h.rem_euclid(360.0), s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)) +} + /// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the /// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times /// along the vector (around the circle, for a conic), and keep going past both of its ends. @@ -192,10 +288,11 @@ const MAX_INK_TINTS: usize = 4096; const INK_MIX_STEPS: u32 = 64; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly -/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved -/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order. -fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) { - let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb(); +/// stays within [`PERCEPTUAL_TOLERANCE`] of `method`'s mix of `a` and `b` (Perceptual, or around +/// the hue wheel): the span halved where a straight line strays, at most [`PERCEPTUAL_DEPTH`] +/// times, in order. +fn perceptual_steps(method: GradientMethod, a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) { + let at = |v: f32| method.mix(a, b, v).to_rgb(); let (c0, c1) = (at(v0), at(v1)); let stray = [0.25, 0.5, 0.75] .into_iter() @@ -206,19 +303,20 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: .fold(0.0, f32::max); if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE { let m = (v0 + v1) / 2.0; - perceptual_steps(a, b, (v0, m), depth + 1, out); + perceptual_steps(method, a, b, (v0, m), depth + 1, out); out.push(m); - perceptual_steps(a, b, (m, v1), depth + 1, out); + perceptual_steps(method, a, b, (m, v1), depth + 1, out); } } -/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a` -/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½). -fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec { +/// The mix positions (`v`, 0..1) of the stops the curve of a gradient mixed by `method` +/// (Perceptual, or around the hue wheel) has between stops `a` and `b`, in order: +/// [`perceptual_steps`] on either side of the midpoint, and the midpoint (½). +fn perceptual_mixes(method: GradientMethod, a: &GradientStop, b: &GradientStop) -> Vec { let mut vs = Vec::new(); - perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs); + perceptual_steps(method, &a.color, &b.color, (0.0, 0.5), 1, &mut vs); vs.push(0.5); - perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs); + perceptual_steps(method, &a.color, &b.color, (0.5, 1.0), 1, &mut vs); vs } @@ -512,10 +610,11 @@ impl Gradient { }; (t, c, o, Some(s.midpoint), None) }); - // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the - // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them. + // Perceptual and the hue methods: stops along the mix (`v`) where a straight line would + // stray from it, the midpoint (v = ½) among them, placed through the midpoint as + // [`Self::sample`] maps them. let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| { - perceptual_mixes(s, n).into_iter().map(move |v| { + perceptual_mixes(self.method, s, n).into_iter().map(move |v| { let mid = v == 0.5; let t = curve_offset(s, n, v); let o = s.opacity + (n.opacity - s.opacity) * v; @@ -571,11 +670,12 @@ impl Gradient { /// position itself when the two colors' lightness differs by less than /// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere /// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even - /// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more - /// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + /// steps), as from red to blue. `None` for a Classic gradient, for one mixed around the hue + /// wheel (its colors between two stops are not mixes of the stops' inks), and when the curve + /// has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). pub fn ink_mixes(&self) -> Option> { use crate::libraries::srgb_to_oklab; - if self.method.is_classic() || !self.fits_ink_tints() { + if self.method != GradientMethod::Perceptual || !self.fits_ink_tints() { return None; } let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; @@ -592,7 +692,7 @@ impl Gradient { .collect::>() .windows(2) .all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0); - for v in perceptual_mixes(s, n) { + for v in perceptual_mixes(self.method, s, n) { let weight = if by_position { v } else { @@ -650,8 +750,10 @@ impl Gradient { Some(Ramp { stops }) } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each - /// segment's midpoint mirrored with it. + /// segment's midpoint mirrored with it, and a hue method turned the other way (red to blue + /// through magenta, reversed, is blue to red through magenta). pub fn reverse(&mut self) { + self.method = self.method.mirrored(); self.stops.reverse(); for i in 0..self.stops.len() { // A midpoint belongs to the segment after its stop: reversed, stop i's segment is the @@ -666,6 +768,13 @@ impl Gradient { pub fn sort(&mut self) { self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); } + /// The gradient's colors as a Classic gradient: its curve's stops ([`Self::expanded_stops`]) as + /// stops of their own, mixed in display RGB, within [`PERCEPTUAL_TOLERANCE`] of it. The stops + /// keep no swatch links, tints or process plates. + pub fn as_classic_curve(&self) -> Gradient { + let stops = self.expanded().map(|(t, color, opacity, _)| GradientStop { opacity, ..GradientStop::new(t, color) }).collect(); + Gradient { kind: self.kind, stops, dither: self.dither, method: GradientMethod::Classic, spread: self.spread, repeats: self.repeats } + } /// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient /// of its own running 0 → 1: the same colours (midpoints become stops, as in /// [`Self::expanded_stops`]). One colour throughout when `from == to`. @@ -1261,8 +1370,9 @@ impl GradientPaint { } /// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill /// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a - /// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix - /// linearly between the stops, as SVG's and PDF's gradients do)? + /// dithered fill (dither applies to fills only), or a Perceptual one or one mixed around the + /// hue wheel (its colours don't mix linearly between the stops, as SVG's and PDF's gradients + /// do)? pub fn draws_as_pixels(&self, fill: bool) -> bool { self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic() } diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs index 8f9933c40c..fa111586ba 100644 --- a/crates/color/src/gradient/spread.rs +++ b/crates/color/src/gradient/spread.rs @@ -59,6 +59,9 @@ impl Gradient { if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { return None; } + if self.mirrors_a_hue_turn() { + return self.as_classic_curve().padded_within(geom, area, max_stops); + } let period = geom.period(kind, self.repeat_count()); let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; let ts = corners.map(|p| period.param_at(kind, p)); @@ -128,6 +131,9 @@ impl Gradient { if self.stops.is_empty() { return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); } + if self.mirrors_a_hue_turn() { + return Cow::Owned(self.as_classic_curve().unrolled_within(max_stops).into_owned()); + } let n = self.repeat_count() as usize; let budget = max_stops.min(MAX_SPREAD_STOPS); let kept = n.min(budget / 2).max(1); @@ -159,12 +165,22 @@ impl Gradient { if !self.is_spread() || self.stops.is_empty() { return None; } + if self.mirrors_a_hue_turn() { + return self.as_classic_curve().cycle(); + } let one = whole_run(&self.stops); let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 }; let stops = self.runs(&one, false, 0.0, runs, runs as f64); Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32)) } + /// Does the gradient reflect while mixing around the hue wheel? A mirrored run of its stops + /// would turn the wheel the other way, and one gradient has one method, so its runs are + /// written from its curve's stops ([`Self::as_classic_curve`]), which mirror as they are. + fn mirrors_a_hue_turn(&self) -> bool { + self.method.is_hue() && self.spread == GradientSpread::Reflect + } + /// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1, /// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with /// Reflect, the odd-numbered runs are mirrored). diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs index a88319ccaa..ab088293da 100644 --- a/crates/color/src/gradient/spread/tests.rs +++ b/crates/color/src/gradient/spread/tests.rs @@ -118,6 +118,46 @@ fn reflect_mirrors_every_other_run_and_its_midpoints() { assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops); } +/// A gradient mixed around the hue wheel that reflects: a mirrored run turns the wheel back the +/// way it came, so every point keeps its color when the runs are written out as a gradient that +/// pads, unrolled along the vector, or as one cycle, with a repeating one's too. +#[test] +fn reflected_and_repeated_hue_turns_keep_every_color() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let stops = || { + let mut red = GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)); + red.midpoint = 0.3; + vec![red, GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))] + }; + let near = |a: Color, b: Color, what: &str| { + assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}"); + }; + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + for spread in [GradientSpread::Reflect, GradientSpread::Repeat] { + let g = Gradient { method, spread, repeats: 2, ..Gradient::new(GradientKind::Linear, stops()) }; + let p = g.padded_over(&geom, Rect::new(-10.0, 0.0, 20.0, 10.0)).unwrap(); + assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1)); + for k in 0..300 { + let q = Point::new(-10.0 + f64::from(k) * 0.1 + 0.013, 5.0); + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0; + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0; + near(want, got, &format!("{method:?} {spread:?} padded at {}", q.x)); + } + let unrolled = g.unrolled(); + let (cycle, runs) = g.cycle().unwrap(); + for k in 0..=200 { + let t = (k as f32 + 0.37) / 201.0; + let want = g.sample(g.spread_param(f64::from(t)) as f32).0; + near(want, unrolled.sample(t).0, &format!("{method:?} {spread:?} unrolled at {t}")); + // One cycle covers `runs` of the two runs between 0 and 1. + let along = t * runs as f32 / 2.0; + let want = g.sample(g.spread_param(f64::from(along)) as f32).0; + near(want, cycle.sample(t).0, &format!("{method:?} {spread:?} cycle at {t}")); + } + } + } +} + #[test] fn stops_inside_a_run_hold_their_colors_to_its_ends() { let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 0709d3068b..cd25753a05 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -514,6 +514,283 @@ fn perceptual_gradients_draw_as_pixels() { } } +fn two_stops(a: Color, b: Color, method: GradientMethod) -> Gradient { + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); + g.method = method; + g +} + +/// The hue methods mix around the hue wheel in one direction: Hue Counterclockwise raises the +/// hue angle from each stop to the next and Hue Clockwise lowers it, by the fixed-direction rules +/// of (the angles below are that +/// section's own examples), with saturation and brightness changing evenly. +#[test] +fn hue_methods_turn_one_way_around_the_wheel() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let rising = two_stops(red, blue, GradientMethod::HueCounterclockwise); + assert_eq!(rising.sample(0.0).0.to_hex(), "#ff0000"); + assert_eq!(rising.sample(0.25).0.to_hex(), "#ffff00", "60°"); + assert_eq!(rising.sample(0.5).0.to_hex(), "#00ff00", "120°"); + assert_eq!(rising.sample(1.0).0.to_hex(), "#0000ff"); + let falling = two_stops(red, blue, GradientMethod::HueClockwise); + assert_eq!(falling.sample(0.0).0.to_hex(), "#ff0000"); + assert_eq!(falling.sample(0.5).0.to_hex(), "#ff00ff", "300°"); + assert_eq!(falling.sample(1.0).0.to_hex(), "#0000ff"); + assert_eq!(two_stops(red, blue, GradientMethod::Classic).sample(0.5).0.to_rgb(), [0.5, 0.0, 0.5], "classic: the RGB mix"); + // 30° to 190° and to 230°: halfway is 110° and 130° rising, 290° and 310° falling. + for (to, up, down) in [(190.0, 110.0, 290.0), (230.0, 130.0, 310.0)] { + let (a, b) = (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(to, 0.9, 0.4)); + for (method, want) in [(GradientMethod::HueCounterclockwise, up), (GradientMethod::HueClockwise, down)] { + let [h, s, v] = method.mix(&a, &b, 0.5).to_hsb(); + assert!((h - want).abs() < 0.05, "{method:?} to {to}: hue {h}, not {want}"); + assert!((s - 0.7).abs() < 1e-3 && (v - 0.6).abs() < 1e-3, "{method:?}: saturation {s}, brightness {v}"); + } + } + // The other way round, each method takes the arc the other took. + let back = two_stops(blue, red, GradientMethod::HueClockwise); + assert_eq!(back.sample(0.5).0.to_hex(), "#00ff00"); + // A position that is not a number gives the first color. + assert_eq!(hue_mix(&red, &blue, f32::NAN, true).to_hex(), "#ff0000"); +} + +/// A color without a hue does not turn the wheel: a gray takes the hue of the stop it mixes +/// with, black its saturation too, two grays mix as grays, and two stops of one hue keep that +/// hue, whichever way the method turns. +#[test] +fn hue_methods_keep_the_hue_where_a_stop_has_none() { + let red = Color::rgb(1.0, 0.0, 0.0); + let close = |c: Color, want: [f32; 3], what: &str| { + let got = c.to_rgb(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}, not {want:?}"); + }; + let green = Color::rgb(0.0, 1.0, 0.0); + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + close(method.mix(&Color::WHITE, &green, 0.5), [0.5, 1.0, 0.5], "white to green: a light green"); + close(method.mix(&green, &Color::WHITE, 0.5), [0.5, 1.0, 0.5], "green to white: a light green"); + close(method.mix(&Color::rgb(0.5, 0.5, 0.5), &green, 0.5), [0.375, 0.75, 0.375], "gray to green"); + close(method.mix(&Color::BLACK, &green, 0.5), [0.0, 0.5, 0.0], "black to green: a dark green, not a gray green"); + close(method.mix(&green, &Color::BLACK, 0.25), [0.0, 0.75, 0.0], "green to black"); + close(method.mix(&Color::BLACK, &Color::WHITE, 0.5), [0.5, 0.5, 0.5], "black to white: gray"); + close(method.mix(&Color::BLACK, &Color::BLACK, 0.5), [0.0, 0.0, 0.0], "black to black"); + let dark = Color::rgb(0.5, 0.0, 0.0); + close(method.mix(&red, &dark, 0.5), [0.75, 0.0, 0.0], "one hue: a red between the two"); + close(method.mix(&red, &red, 0.5), [1.0, 0.0, 0.0], "one color stays"); + // One hue with saturation and brightness both changing: the hue stays, and the color is + // the even mix of saturation and brightness, which is not Classic's RGB mix. + let pink = Color::rgb(1.0, 0.8, 0.8); + close(method.mix(&dark, &pink, 0.5), [0.75, 0.3, 0.3], "dark red to pink"); + assert!((Color::lerp(&dark, &pink, 0.5).to_rgb()[1] - 0.4).abs() < 1e-3, "classic differs"); + // A color whose RGB numbers are under half a color level apart has no hue of its own: a + // near gray leaning blue takes green's hue rather than turning from blue. + let near_gray = Color::rgb(0.5, 0.5, 0.5 + 0.3 / 255.0); + let [h, _, _] = method.mix(&near_gray, &green, 0.5).to_hsb(); + assert!((h - 120.0).abs() < 0.5, "{method:?}: near gray to green passes hue {h}"); + // One a whole color level apart has its hue (240°) and turns from it. + let bluish = Color::rgb(128.0 / 255.0, 128.0 / 255.0, 129.0 / 255.0); + let [h, _, _] = method.mix(&bluish, &green, 0.5).to_hsb(); + let want = if method == GradientMethod::HueClockwise { 180.0 } else { 0.0 }; + assert!(((h - want + 180.0).rem_euclid(360.0) - 180.0).abs() < 0.5, "{method:?}: bluish gray to green passes hue {h}, not {want}"); + } +} + +/// Hues that differ only by rounding are one hue: a color and a tint or shade of it, whose +/// computed hue angles differ in their last digits, mix without turning the wheel, with either +/// method, and a stop added to a ramp leaves the ramp's colors as they were. Hues a whole color +/// level apart still turn. +#[test] +fn hue_methods_take_hues_within_half_a_color_level_as_one() { + let level = |n: u8| f32::from(n) / 255.0; + let rgb8 = |r: u8, g: u8, b: u8| Color::rgb(level(r), level(g), level(b)); + let methods = [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise]; + // Colors against their 50% tint toward white and a shade at 40% brightness. At least one + // pair's computed hues differ, or the test checks nothing. + let colors = [rgb8(60, 253, 230), rgb8(241, 194, 107), rgb8(68, 32, 130), rgb8(13, 200, 77), rgb8(250, 3, 201), rgb8(90, 91, 200)]; + let mut uneven = 0; + for c in colors { + let [h, s, v] = c.to_hsb(); + let tint = Color::lerp(&c, &Color::WHITE, 0.5); + let shade = Color::from_hsb(h, s, v * 0.4); + for other in [tint, shade] { + let [h1, _, _] = other.to_hsb(); + assert!((h1 - h).abs() < 0.01, "{c:?} and {other:?} are one hue: {h} and {h1}"); + uneven += usize::from(h1 != h); + for method in methods { + for (a, b) in [(c, other), (other, c)] { + for k in 0..=20 { + let [hm, _, _] = method.mix(&a, &b, k as f32 / 20.0).to_hsb(); + let off = (hm - h + 180.0).rem_euclid(360.0) - 180.0; + assert!(off.abs() < 0.5, "{method:?} {a:?} → {b:?} at {k}/20: hue {hm}, not {h}"); + } + } + } + } + } + assert!(uneven > 0, "no pair's hues differ by rounding"); + // Across 0°: 359.99° and 0.01° are one hue too. + for method in methods { + let [h, _, _] = method.mix(&Color::from_hsb(359.99, 1.0, 1.0), &Color::from_hsb(0.01, 1.0, 1.0), 0.5).to_hsb(); + assert!(!(1.0..=359.0).contains(&h), "{method:?} across 0°: {h}"); + } + // A stop added to a ramp samples the ramp's color there, and the ramp keeps its colors: + // white to red, a color to its tint, and a turn from red to blue. + let ramps = [ + (Color::WHITE, rgb8(255, 0, 0)), + (rgb8(60, 253, 230), Color::lerp(&rgb8(60, 253, 230), &Color::WHITE, 0.5)), + (rgb8(255, 0, 0), rgb8(0, 0, 255)), + ]; + for method in methods { + for (a, b) in ramps { + let g = two_stops(a, b, method); + for at in [0.21, 0.5, 0.83] { + let (stops, i) = insert_stop(&g, at); + assert_eq!((stops.len(), i), (3, 1)); + let with = Gradient { stops, ..g.clone() }; + for k in 0..=200 { + let t = k as f32 / 200.0; + let (was, now) = (g.sample(t).0.to_rgb(), with.sample(t).0.to_rgb()); + let off = was.iter().zip(now).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 1.0 / 255.0, "{method:?} {a:?} → {b:?}, a stop at {at}: {was:?} became {now:?} at {t}"); + } + } + } + } + // One color level of hue is a hue of its own: red to a red one level toward yellow turns + // nearly the whole wheel one way and stays put the other. + let (red, next) = (rgb8(255, 0, 0), rgb8(255, 1, 0)); + let [h, _, _] = GradientMethod::HueClockwise.mix(&red, &next, 0.5).to_hsb(); + assert!((h - 180.0).abs() < 1.0, "the long way: {h}"); + let [h, _, _] = GradientMethod::HueCounterclockwise.mix(&red, &next, 0.5).to_hsb(); + assert!(h < 1.0, "the short way: {h}"); +} + +/// Renderers mix linearly between stops: a hue segment becomes stops on its way around the +/// wheel, close enough that the linear ramp between them stays within half a color level of it, +/// through the midpoint and with the opacity changing evenly. +#[test] +fn hue_segments_expand_into_stops_on_the_curve() { + let pairs = [ + (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)), + (Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 0.02, 0.0)), + (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(190.0, 0.9, 0.4)), + (Color::cmyk(0.0, 0.9, 0.9, 0.0), Color::WHITE), + (Color::BLACK, Color::rgb(0.0, 1.0, 0.0)), + ]; + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + for (a, b) in pairs { + let mut g = two_stops(a, b, method); + g.stops[0].midpoint = 0.3; + g.stops[1].opacity = 0.5; + let ex: Vec<_> = g.expanded().collect(); + assert!((3..=600).contains(&ex.len()), "{method:?} {a:?} → {b:?}: {} stops", ex.len()); + assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); + assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6), "a stop stands for the midpoint"); + let ramp = Ramp::new(&g, &Color::to_rgb); + for k in 0..=4000 { + let t = k as f32 / 4000.0; + let (c, o) = g.sample(t); + let [r, gr, bl] = c.to_rgb(); + let off = [r, gr, bl, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 0.5 / 255.0, "{method:?} {a:?} → {b:?} at {t}: {off}"); + } + assert_eq!(g.sample_keep_model(0.4), g.sample(0.4), "one mix"); + } + } +} + +/// Reversing a gradient gives the same ramp the other way round: a hue method turns the other +/// way with the stops, so every color keeps its place mirrored, and reversing twice gives the +/// gradient back. Classic and Perceptual keep their method. A backward span and the curve as a +/// Classic gradient hold the same colors. +#[test] +fn reversing_a_hue_gradient_keeps_its_colors_mirrored() { + let near = |a: Color, b: Color, what: &str| { + assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}"); + }; + for (method, other) in + [(GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise), (GradientMethod::HueCounterclockwise, GradientMethod::HueClockwise)] + { + let mut g = Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.3, ..GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)) }, + GradientStop { opacity: 0.5, ..GradientStop::new(0.6, Color::rgb(0.0, 0.0, 1.0)) }, + GradientStop::new(1.0, Color::rgb(0.9, 0.8, 0.1)), + ], + ); + g.method = method; + let mut back = g.clone(); + back.reverse(); + assert_eq!(back.method, other); + let (span, curve) = (g.span(1.0, 0.0), g.as_classic_curve()); + assert!(span.method.is_classic() && curve.method.is_classic()); + assert!(curve.stops.len() > g.stops.len() && curve.stops.iter().all(|s| s.swatch.is_none() && s.midpoint == 0.5)); + for k in 0..=400 { + let t = k as f32 / 400.0; + let (want, opacity) = g.sample(1.0 - t); + near(want, back.sample(t).0, &format!("{method:?} reversed at {t}")); + assert!((opacity - back.sample(t).1).abs() < 1e-4, "{method:?} opacity at {t}"); + near(want, span.sample(t).0, &format!("{method:?} backward span at {t}")); + near(g.sample(t).0, curve.sample(t).0, &format!("{method:?} curve at {t}")); + } + back.reverse(); + assert_eq!(back.method, method); + assert_eq!(back.stops.len(), g.stops.len()); + } + for method in [GradientMethod::Classic, GradientMethod::Perceptual] { + assert_eq!(method.mirrored(), method); + let mut g = blue_yellow(method); + g.reverse(); + assert_eq!(g.method, method); + } +} + +/// The hue methods are written by name, read back, and parsed from their param names; their +/// gradients draw as pixels in SVG and PDF, and are not mixes of the stops' inks. +#[test] +fn hue_methods_save_parse_and_draw_as_pixels() { + for (method, name, id) in [ + (GradientMethod::HueClockwise, "HueClockwise", "hue-clockwise"), + (GradientMethod::HueCounterclockwise, "HueCounterclockwise", "hue-counterclockwise"), + ] { + let g = two_stops(Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::cmyk(1.0, 1.0, 0.0, 0.0), method); + let v = serde_json::to_value(&g).unwrap(); + assert_eq!(v["method"], name); + assert_eq!(serde_json::from_value::(v).unwrap(), g); + assert_eq!(method.id(), id); + for written in [id.to_string(), id.to_uppercase(), id.replace('-', " "), id.replace('-', "_"), name.to_string(), method.label().to_string()] { + assert_eq!(GradientMethod::parse(&written), Some(method), "{written}"); + } + assert!(method.is_hue() && !method.is_classic()); + let paint = GradientPaint::new(g.clone()); + assert!(paint.draws_as_pixels(true) && paint.draws_as_pixels(false)); + assert!(g.ink_mixes().is_none(), "not mixes of the stops' inks"); + assert!( + paint.ink_pixels(Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(0.0, 0.0, 10.0, 10.0), 4, 4, true).is_none(), + "an RGB image, not an ink image" + ); + } + assert!(!GradientMethod::Perceptual.is_hue() && !GradientMethod::Classic.is_hue()); + for (id, method) in [("classic", GradientMethod::Classic), ("perceptual", GradientMethod::Perceptual)] { + assert_eq!(method.id(), id); + assert_eq!(GradientMethod::parse(id), Some(method)); + } + for bad in [ + "hue", + "clockwise", + "hue-", + "", + "hue clockwise wise", + "c-l-a-s-s-i-c", + "per ceptual", + "hue--clockwise", + "hue-clock_wise", + " classic", + "hue- clockwise", + ] { + assert_eq!(GradientMethod::parse(bad), None, "{bad}"); + } +} + /// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on /// the curve has the lightness of the color mixed there, so the tints between the stops are not /// a straight line. diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 0d84e9e6b3..3afca8063c 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -102,12 +102,13 @@ impl Color { pub fn to_lab(&self) -> cms::Lab { cms::active().lab(self) } - /// Colour-managed CMYK in the working CMYK space with `intent` (see [`Color::to_cmyk`] for the - /// profile-free formula). + /// Color-managed CMYK in the active CMYK space (the front document's assigned CMYK profile, else + /// the working CMYK space) with `intent` (see [`Color::to_cmyk`] for the profile-free formula). pub fn to_cmyk_managed(&self, intent: cms::Intent) -> [f32; 4] { cms::active().to_cmyk(self, intent) } - /// Gamut warning: true when this colour can't be reproduced in the working CMYK space. + /// Gamut warning: true when this color can't be reproduced in the active CMYK space (the front + /// document's assigned CMYK profile, else the working CMYK space). pub fn out_of_gamut(&self) -> bool { cms::active().out_of_gamut(self) } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d170bd606f..d936891514 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1205,6 +1205,13 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> // Spine // ===================================================================================== +#[cfg(test)] +thread_local! { + /// How many times [`Spine::new`] has run on this thread, for tests that count how often a path + /// is flattened. + pub(crate) static SPINES_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the /// arc length at each of its anchors. pub struct Spine { @@ -1223,6 +1230,8 @@ impl Spine { /// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when /// the first one flattens to fewer than two points (so it has no length to walk). pub fn new(path: &PathData) -> Option { + #[cfg(test)] + SPINES_BUILT.with(|n| n.set(n.get() + 1)); let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); let mut cum: Vec = Vec::new(); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 057a9df6a6..418e454b92 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -78,6 +78,7 @@ impl Document { units_from_file: _, index: _, guide_cache: _, + inline_cycles: _, } = self; *version == other.version && *title == other.title @@ -262,6 +263,7 @@ impl Document { units_from_file: false, index: Default::default(), guide_cache: Default::default(), + inline_cycles: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index c5bf1972d8..a8e3955015 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -2,10 +2,13 @@ //! from the document's symbols, which the text layout needs to size and place it. use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock, Weak}; use vectorcraft_geom::{Affine, Rect}; -use crate::{Document, NodeId, NodeKind, TextObject}; +use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject}; /// The document key under which the app records each symbol's natural size (`{name: [w, h]}`). pub const SYMBOL_SIZES: &str = "symbolSizes"; @@ -39,9 +42,27 @@ impl Document { usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b))) } + /// The art bounds an inline graphic of the symbol named `name` takes: [`Self::symbol_art_bounds`], + /// or `None` (an empty slot) when the symbol's art shows the symbol itself inline, directly or + /// through the inline graphics in other symbols' art, so that its size would depend on itself. + pub fn inline_art_bounds(&self, name: &str) -> Option { + if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) } + } + + /// The names of the symbols that reach themselves through the inline graphics in symbol art, + /// found once while the symbols stay as they are. + fn inline_cycles(&self) -> Arc> { + if let Some(found) = self.inline_cycles.get(&self.symbols) { + return found; + } + let found = Arc::new(InlineGraph::of(self).cyclic); + self.inline_cycles.set(&self.symbols, &found); + found + } + /// Does `t` have inline graphics whose resolved bounds differ from the document's symbols? fn inline_stale(&self, t: &TextObject) -> bool { - t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol)) + stale_by(t, &|n| self.inline_art_bounds(n)) } /// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed @@ -56,54 +77,421 @@ impl Document { Cow::Owned(t) } - /// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's - /// cached layout bounds are then cleared). + /// Fill in the art bounds of `t`'s inline graphics ([`Self::inline_art_bounds`]); returns + /// whether any changed (the text's cached layout is then cleared). pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool { - let mut changed = false; - for r in &mut t.runs { - if let Some(a) = &mut r.inline { - let b = self.symbol_art_bounds(&a.symbol); - if a.bounds != b { - a.bounds = b; - changed = true; - } - } - } - if changed { - t.cached_bounds = None; - t.cached_ink = None; - } - changed + resolve_runs(t, &|n| self.inline_art_bounds(n)) } - /// Resolve the inline graphics of every text object in the layers (after opening a document - /// or changing its symbols). Returns the ids of the texts that changed: their cached layout - /// bounds were cleared and need recomputing. + /// Resolve the inline graphics of every text object in the document (after opening a document + /// or changing its symbols), without laying the type out again: see + /// [`Self::resolve_inline_art_with`]. pub fn resolve_inline_art(&mut self) -> Vec { - let mut stale = vec![]; - self.walk(|n| { - if let NodeKind::Text(t) = &n.kind - && t.runs.iter().any(|r| r.inline.is_some()) - && self.inline_stale(t) - { - stale.push(n.id); - } - }); + self.resolve_inline_art_with(&mut |_| {}) + } + + /// Resolve the inline graphics of every text object in the document: in the layers, the art + /// of symbols, graph designs and pattern tiles, and the opacity masks and live objects in + /// them. Each text whose inline graphics changed has its cached layout cleared and is passed + /// to `layout` to be laid out again. The symbols are resolved first, each after the symbols its + /// inline graphics show, so a symbol's art is measured with its type already laid out; a + /// symbol that shows itself inline ([`Self::inline_art_bounds`]) is shown as an empty slot. + /// Returns the ids of the texts in the layers that changed. + pub fn resolve_inline_art_with(&mut self, layout: &mut dyn FnMut(&mut TextObject)) -> Vec { let mut out = vec![]; - for id in stale { - let Some(mut t) = self.node(id).and_then(|n| match &n.kind { - NodeKind::Text(t) => Some(t.clone()), - _ => None, - }) else { - continue; - }; - if self.resolve_text_inline(&mut t) - && let Some(n) = self.node_mut(id) - { - n.kind = NodeKind::Text(t); - out.push(id); + let mut unused = vec![]; + let graph = InlineGraph::of(self); + let measured = Measured::default(); + for &i in &graph.order { + let mut art = self.symbols[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.symbols[i].art = art; + } + } + for i in 0..self.layers.len() { + let mut layer = self.layers[i].clone(); + if resolve_tree(&mut layer, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut out) { + self.layers[i] = layer; + } + } + for i in 0..self.graph_designs.len() { + let mut art = self.graph_designs[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.graph_designs[i].art = art; + } + } + for i in 0..self.patterns.len() { + for j in 0..self.patterns[i].art.len() { + let mut art = self.patterns[i].art[j].clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.patterns[i].art[j] = art; + } } } out } } + +/// Whether `t` has inline graphics whose bounds differ from what `bounds` gives their symbols. +fn stale_by(t: &TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != bounds(&a.symbol)) +} + +/// Gives `t`'s inline graphics the bounds `bounds` gives their symbols; returns whether any changed +/// (the text's cached layout is then cleared). +fn resolve_runs(t: &mut TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + let mut changed = false; + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + let b = bounds(&a.symbol); + if a.bounds != b { + a.bounds = b; + changed = true; + } + } + if changed { + t.cached_bounds = None; + t.cached_baselines.clear(); + t.cached_ink = None; + } + changed +} + +/// Resolves the inline graphics of the type in tree `n` (opacity mask art included) with +/// `bounds`, passing each text that changed to `layout` and adding its id to `changed`; returns +/// whether the tree changed. Each node is visited once, and only the nodes on the way to changed +/// type are copied. +fn resolve_tree( + n: &mut Arc, + bounds: &dyn Fn(&str) -> Option, + layout: &mut dyn FnMut(&mut TextObject), + changed: &mut Vec, +) -> bool { + let mut copied = false; + if matches!(&n.kind, NodeKind::Text(t) if stale_by(t, bounds)) { + let node = Arc::make_mut(n); + if let NodeKind::Text(t) = &mut node.kind + && resolve_runs(t, bounds) + { + layout(t); + changed.push(node.id); + } + copied = true; + } + let mask = n.extras().mask.as_deref().and_then(|m| { + let mut art = m.art.clone(); + resolve_tree(&mut art, bounds, layout, changed).then_some(art) + }); + let mut kids = vec![]; + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut c = c.clone(); + if resolve_tree(&mut c, bounds, layout, changed) { + kids.push((i, c)); + } + } + if mask.is_none() && kids.is_empty() { + return copied; + } + let node = Arc::make_mut(n); + if let Some(art) = mask + && let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + { + m.art = art; + } + if let Some(ch) = node.children_mut() { + for (i, c) in kids { + ch[i] = c; + } + } + true +} + +/// The art bounds of each symbol, measured once in a pass of [`Document::resolve_inline_art_with`]. +/// A symbol is first asked for after its own type is resolved (the pass goes in +/// [`InlineGraph::order`]), so its measurement stays current for the rest of the pass. +#[derive(Default)] +struct Measured(RefCell>>); + +impl Measured { + /// The bounds kept for `name`, or `measure`'s, kept for later. + fn get(&self, name: &str, measure: impl FnOnce() -> Option) -> Option { + if let Some(&b) = self.0.borrow().get(name) { + return b; + } + let b = measure(); + self.0.borrow_mut().insert(name.to_owned(), b); + b + } +} + +/// The names of the symbols the inline graphics in tree `n` show (opacity mask art included). +fn inline_symbols(n: &Node) -> Vec { + fn collect(n: &Node, out: &mut Vec) { + if let NodeKind::Text(t) = &n.kind { + out.extend(t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))); + } + if let Some(m) = n.extras().mask.as_deref() { + collect(&m.art, out); + } + for c in n.children().into_iter().flatten() { + collect(c, out); + } + } + let mut out = vec![]; + collect(n, &mut out); + out +} + +/// Each symbol name of `d` → the index of the first symbol of that name (the one a name finds). +fn symbol_index(d: &Document) -> HashMap<&str, usize> { + let mut index = HashMap::with_capacity(d.symbols.len()); + for (i, s) in d.symbols.iter().enumerate() { + index.entry(s.name.as_str()).or_insert(i); + } + index +} + +/// The symbols of a document as their inline graphics depend on each other. +struct InlineGraph { + /// Every symbol index, each after the symbols its art shows inline (except along a cycle). + order: Vec, + /// The names of the symbols that reach themselves through inline graphics. + cyclic: HashSet, +} + +impl InlineGraph { + /// The graph of `d`'s symbols: each symbol's art is walked once, and the order and cycles are + /// found in time linear in the symbols and their inline references (Tarjan's strongly + /// connected components, without recursion). + fn of(d: &Document) -> Self { + let index = symbol_index(d); + let edges: Vec> = d + .symbols + .iter() + .map(|s| { + let mut e: Vec = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect(); + e.sort_unstable(); + e.dedup(); + e + }) + .collect(); + let (order, cyclic) = components(&edges); + let cyclic = order.iter().filter(|&&i| cyclic[i]).map(|&i| d.symbols[i].name.clone()).collect(); + Self { order, cyclic } + } + + /// [`Document::inline_art_bounds`] with the cycles found in this graph. + fn bounds(&self, d: &Document, name: &str) -> Option { + if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) } + } +} + +/// The nodes of the graph `edges` (node → the nodes it depends on), each after the nodes it +/// depends on except within a cycle, and whether each node is on a cycle (a component of more than +/// one node, or an edge to itself). +fn components(edges: &[Vec]) -> (Vec, Vec) { + const UNSEEN: usize = usize::MAX; + let n = edges.len(); + let (mut index, mut low, mut on_stack) = (vec![UNSEEN; n], vec![0; n], vec![false; n]); + let (mut stack, mut order, mut cyclic) = (vec![], Vec::with_capacity(n), vec![false; n]); + let mut next = 0; + // The nodes being visited, each with the position of its next edge. + let mut calls: Vec<(usize, usize)> = vec![]; + for root in 0..n { + if index[root] != UNSEEN { + continue; + } + index[root] = next; + low[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + calls.push((root, 0)); + while let Some(top) = calls.last_mut() { + let v = top.0; + if let Some(&w) = edges[v].get(top.1) { + top.1 += 1; + if index[w] == UNSEEN { + index[w] = next; + low[w] = next; + next += 1; + stack.push(w); + on_stack[w] = true; + calls.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + calls.pop(); + if let Some(&(u, _)) = calls.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == index[v] { + let start = order.len(); + while let Some(w) = stack.pop() { + on_stack[w] = false; + order.push(w); + if w == v { + break; + } + } + if order.len() - start > 1 || edges[v].contains(&v) { + for &w in &order[start..] { + cyclic[w] = true; + } + } + } + } + } + (order, cyclic) +} + +/// The symbols [`Document::inline_cycles`] last found, with the name of each symbol and a weak +/// pointer to its art. While a weak pointer to an art's allocation is held, nothing else can be at +/// that address, and the art can't be changed in place (`Arc::get_mut` refuses it and +/// `Arc::make_mut` moves it), so any edit inside a symbol's art, and any symbol added, removed, +/// renamed or replaced, gives symbols the cache does not match. Clones of a document share it. +#[derive(Default)] +pub(crate) struct InlineCycles(RwLock>>); + +/// The name and art of each symbol the cycles were found in, and the names of the symbols on one. +type Found = (Vec<(String, Weak)>, Arc>); + +impl InlineCycles { + /// The cached names, when they were found in `symbols`. + fn get(&self, symbols: &[Symbol]) -> Option>> { + let g = self.0.read().ok()?; + let (from, found) = &**g.as_ref()?; + let same = from.len() == symbols.len() + && from.iter().zip(symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art))); + same.then(|| found.clone()) + } + + /// Keep `found`, found in `symbols`. + fn set(&self, symbols: &[Symbol], found: &Arc>) { + if let Ok(mut g) = self.0.write() { + *g = Some(Arc::new((symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(), found.clone()))); + } + } +} + +impl Clone for InlineCycles { + fn clone(&self) -> Self { + InlineCycles(RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for InlineCycles { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for InlineCycles { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("InlineCycles") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The position of each node in `order`. + fn positions(order: &[usize]) -> Vec { + let mut at = vec![0; order.len()]; + for (p, &i) in order.iter().enumerate() { + at[i] = p; + } + at + } + + #[test] + fn components_put_dependencies_first_and_find_every_cycle_member() { + // 0 → 1 → 2 → 0 is a cycle, and 3, reached from 1, joins it through 2. + // 4 depends on 0 and is on no cycle; 5 shows itself. + let edges = vec![vec![1], vec![2, 3], vec![0], vec![2], vec![0], vec![5]]; + let (order, cyclic) = components(&edges); + let mut sorted = order.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, [0, 1, 2, 3, 4, 5]); + assert_eq!(cyclic, [true, true, true, true, false, true]); + let at = positions(&order); + assert!(at[4] > at[0], "{order:?}"); + // A chain: each node after the one it depends on. + let (order, cyclic) = components(&[vec![], vec![0], vec![1]]); + assert_eq!(order, [0, 1, 2]); + assert!(cyclic.iter().all(|c| !c)); + } + + /// Type at the bottom of 2,000 nested groups is checked once, not once for each group above it, + /// and only the groups on the way to it are copied. + #[test] + fn resolving_deeply_nested_type_visits_each_node_once() { + use std::cell::Cell; + use vectorcraft_geom::Point; + + let mut t = TextObject::point(Point::ZERO, "Type", crate::CharStyle::default()); + t.runs.push(crate::TextRun::new("", crate::CharStyle::default())); + t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + let depth = 2_000; + let mut tree = Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(t)))); + for i in 1..=depth { + let side = Arc::new(Node::group(NodeId(depth + i), vec![])); + tree = Arc::new(Node::group(NodeId(i), vec![side, tree])); + } + let before = tree.clone(); + let asked = Cell::new(0); + let r = Rect::new(0.0, 0.0, 10.0, 20.0); + let bounds = |_: &str| { + asked.set(asked.get() + 1); + Some(r) + }; + let (mut laid_out, mut changed) = (0, vec![]); + assert!(resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(asked.get() <= 2, "bounds asked {} times for one inline graphic", asked.get()); + assert_eq!(laid_out, 1); + assert_eq!(changed, [NodeId(0)]); + // The bottom of the new tree holds the resolved type; the side groups are shared. + let (mut new, mut old) = (&tree, &before); + while let (Some(n), Some(o)) = (new.children(), old.children()) { + assert!(Arc::ptr_eq(&n[0], &o[0])); + (new, old) = (&n[1], &o[1]); + } + let NodeKind::Text(t) = &new.kind else { panic!("the type is at the bottom") }; + assert_eq!(t.runs[1].inline.as_ref().and_then(|a| a.bounds), Some(r)); + // Nothing left to change: the tree is kept and the type is checked once. + asked.set(0); + let kept = tree.clone(); + assert!(!resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(Arc::ptr_eq(&tree, &kept)); + assert_eq!(asked.get(), 1); + } + + #[test] + fn a_symbol_is_measured_once_in_a_pass() { + let measured = Measured::default(); + let count = std::cell::Cell::new(0); + let measure = |b: Option| { + count.set(count.get() + 1); + b + }; + let r = Some(Rect::new(0.0, 0.0, 1.0, 1.0)); + for _ in 0..3 { + assert_eq!(measured.get("A", || measure(r)), r); + assert_eq!(measured.get("Missing", || measure(None)), None); + } + assert_eq!(count.get(), 2); + } + + #[test] + fn components_handle_a_long_chain_without_recursion() { + let n = 200_000; + let edges: Vec> = (0..n).map(|i| if i + 1 < n { vec![i + 1] } else { vec![] }).collect(); + let (order, cyclic) = components(&edges); + assert_eq!(order.first(), Some(&(n - 1))); + assert_eq!(order.last(), Some(&0)); + assert!(cyclic.iter().all(|c| !c)); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 63a2676f77..e6986ad00e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -362,6 +362,10 @@ pub struct Document { /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. #[serde(skip)] guide_cache: document_guides::GuideCache, + /// The symbols that show themselves inline ([`Document::inline_art_bounds`]): a cache, never + /// saved. + #[serde(skip)] + inline_cycles: inline::InlineCycles, } fn ppi72() -> f64 { diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 52453fd5a6..20d0623d4d 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -54,8 +54,8 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds, - envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, - set_type_ink, smooth_handles, warp_grid, + envelope_content_boxes, envelope_content_boxes_with, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, + envelope_overlay, envelope_stroke_margin, set_type_ink, smooth_handles, warp_grid, }; pub(crate) use expand::max_outset; pub use expand::{ @@ -1247,4 +1247,60 @@ mod tests { assert_eq!(m, before); } } + + /// The pieces of a mesh-warped image make a knockout group, each knocking out its triangle + /// (clipping path or opacity mask art); the group keeps the image's transparency. + #[test] + fn mesh_warp_image_pieces_knock_out_by_their_triangles() { + let im = crate::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone()))); + n.opacity = 0.5; + n.blend = crate::color::BlendMode::Multiply; + let same = |p: Point| p; + for masked in [false, true] { + let g = mesh_warp_image(&n, &im, &same, 2, false, masked); + assert_eq!((g.knockout, g.opacity, g.blend, g.isolate), (crate::Knockout::On, 0.5, crate::color::BlendMode::Multiply, false)); + let NodeKind::Group { children, clip: false } = &g.kind else { panic!("a group") }; + assert_eq!(children.len(), 8); + for c in children { + assert!(c.extras().warp_piece); + let outline = c.knockout_outline().unwrap_or_else(|| panic!("masked {masked}: an outline")); + let NodeKind::Path { path, .. } = &outline.kind else { panic!("a path") }; + assert_eq!(path.anchor_count(), 3); + } + } + // A clip group or masked image that is not a piece knocks out by what it paints. + let mut plain = mesh_warp_image(&n, &im, &same, 1, false, false); + let NodeKind::Group { children, .. } = &mut plain.kind else { panic!("a group") }; + let piece = Arc::make_mut(&mut children[0]); + piece.extras_mut().warp_piece = false; + assert!(piece.knockout_outline().is_none()); + } + + /// More cells than the objects live art may make cut no pieces (the count overflowed). + #[test] + fn mesh_warp_image_past_the_budget_makes_an_empty_group() { + let im = crate::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone()))); + for cells in [usize::MAX, 1000] { + let g = mesh_warp_image(&n, &im, &|p| p, cells, false, false); + assert!(matches!(&g.kind, NodeKind::Group { children, .. } if children.is_empty()), "{cells}"); + } + } } diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 44dc406c1d..5aa43b0a67 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -149,20 +149,31 @@ impl EnvelopeOptions { /// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as /// [`Node::transform`] scales them. pub fn envelope_content_boxes(content: &[Arc], frame: Affine) -> Option<(Rect, Rect)> { + envelope_content_boxes_with(content, frame, false).or_else(|| envelope_content_boxes_with(content, frame, true)) +} + +/// [`envelope_content_boxes`] with type that draws nothing given its laid-out box when +/// `blank_type_box` is set and no box when it is not. A caller that measures the members of an +/// envelope's contents one at a time passes `true` only when the whole contents have no box with +/// `false`, so each member is measured as the whole contents measure it. +pub fn envelope_content_boxes_with(content: &[Arc], frame: Affine, blank_type_box: bool) -> Option<(Rect, Rect)> { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); - let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| { - (vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame))) - }); - Some((stroked?, plain?)) + Some((content_set_box(content, true, to_frame, blank_type_box)?, content_set_box(content, false, to_frame, blank_type_box)?)) } /// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or /// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead /// of its laid-out box. Type keeps its laid-out box while nothing is registered with -/// [`set_type_ink`], and when it draws nothing. +/// [`set_type_ink`]. Type that draws nothing adds no box while other content has one; when +/// nothing else has a box, it keeps its laid-out box. pub fn envelope_content_bounds(content: &[Arc]) -> Option { - content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY))) + content_set_box(content, false, Affine::IDENTITY, false).or_else(|| content_set_box(content, false, Affine::IDENTITY, true)) +} + +/// The union of the boxes [`content_box`] gives each of `content`. +fn content_set_box(content: &[Arc], stroked: bool, to: Affine, blank_type_box: bool) -> Option { + content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, stroked, to, blank_type_box))) } /// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by @@ -367,15 +378,16 @@ fn find_type(n: &Node, typed: &mut std::collections::HashSet) -> bool { /// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them), /// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups /// and layers too. Type is laid out as it is on the page, and its outlines are then taken into -/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. -fn content_box(n: &Arc, stroked: bool, to: Affine) -> Option { +/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. Type +/// that draws nothing has its laid-out box with `blank_type_box`, and no box without it. +fn content_box(n: &Arc, stroked: bool, to: Affine, blank_type_box: bool) -> Option { let mut typed = std::collections::HashSet::new(); find_type(n, &mut typed); - content_box_in(n, stroked, to, &typed) + content_box_in(n, stroked, to, &typed, blank_type_box) } /// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]). -fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet) -> Option { +fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet, blank_type_box: bool) -> Option { // `n` in the axes of `to`. let moved = || { if to == Affine::IDENTITY { @@ -395,10 +407,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect let ink = match n.projection() { None => type_ink(n, t, to * t.xf, None), Some(h) => type_ink(n, t, t.xf, Some((h, to))), - } - .flatten(); + }; let m = moved(); - let Some(ink) = ink else { return own(&m) }; + let ink = match ink { + Some(Some(ink)) => ink, + Some(None) if !blank_type_box => return None, + _ => return own(&m), + }; if !stroked { return Some(ink); } @@ -413,7 +428,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`. let clip = children.first()?; - content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed) + content_box_in( + clip, + stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), + to, + typed, + blank_type_box, + ) } NodeKind::Layer { .. } | NodeKind::Group { .. } => { let layer = matches!(n.kind, NodeKind::Layer { .. }); @@ -424,9 +445,11 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false }); own_strokes.appearance = n.appearance.clone(); own_strokes.transform(to, true); - own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed))) + own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed, blank_type_box))) } else { - members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed))) + members + .filter(|c| c.visible || !layer) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed, blank_type_box))) } } _ => own(&moved()), @@ -649,7 +672,7 @@ impl<'a> EnvelopeMap<'a> { pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); - let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?; + let src = content_set_box(content, false, to_frame, false).or_else(|| content_set_box(content, false, to_frame, true))?; let src = options.source_box(src); Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) } @@ -835,14 +858,14 @@ mod tests { let to = Affine::scale(2.0); let mut moved = (*group).clone(); moved.transform(to, true); - let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to))); + let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to, true))); assert!(want.is_some()); - assert_eq!(content_box(&group, true, to), want); + assert_eq!(content_box(&group, true, to, true), want); let mut nested = group; for i in 0..300 { nested = Arc::new(Node::group(NodeId(10 + i), vec![nested])); } - assert!(content_box(&nested, false, Affine::IDENTITY).is_some()); + assert!(content_box(&nested, false, Affine::IDENTITY, true).is_some()); } /// A warp envelope of `style` at `bend` with map `version` around a 200×100 rectangle at diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index b8309f675b..d87d7a28c4 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -207,9 +207,21 @@ pub fn mesh_warp_cost(cells: usize) -> usize { /// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past /// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the /// color `n` colorizes a grayscale image with, and `n`'s color mask. The result is a group with `n`'s id, name, -/// transparency, opacity mask and appearance. +/// transparency, opacity mask and appearance. It is a knockout group and each piece is a +/// [`crate::NodeExtras::warp_piece`], so where pieces overlap only the top one draws and a +/// partly transparent image keeps its own alpha there. More cells than [`MAX_GENERATED`] objects +/// allow make an empty group. pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node { let k = cells.max(1); + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vec![], clip: false }; + g.knockout = crate::Knockout::On; + if let Some(e) = g.extras_if_any() { + e.colorize = None; + } + if mesh_warp_cost(k) > MAX_GENERATED { + return g; + } // A grayscale image's fill color colorizes each piece; the group of pieces takes none. let colorize = n.extras().colorize.clone(); // Each piece shows what the image shows: its color mask's masked pixels. @@ -243,22 +255,22 @@ pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, c // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); - pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))))); + let mut piece = image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true)))); + piece.extras_mut().warp_piece = true; + pieces.push(Arc::new(piece)); continue; } let mut clip = Node::path(n.id, shape, Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } - pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); + let mut piece = Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }); + piece.extras_mut().warp_piece = true; + pieces.push(Arc::new(piece)); } } } - let mut g = n.clone(); g.kind = NodeKind::Group { children: pieces, clip: false }; - if let Some(e) = g.extras_if_any() { - e.colorize = None; - } g } diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 7b130bf395..e13750457e 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -75,6 +75,10 @@ pub struct NodeExtras { /// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are /// ([`crate::color_mask`]). pub color_mask: Option>, + /// A piece of an image a raster mesh warp cut up ([`crate::live::mesh_warp_image`]): in a + /// knockout group its knockout shape is the whole area of its triangle, not only where the + /// image is opaque ([`Node::knockout_outline`]). + pub warp_piece: bool, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -103,6 +107,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { envelope_edit_margin: None, pixel_aligned: false, color_mask: None, + warp_piece: false, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 415fc1cad1..2bb655236a 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -716,6 +716,37 @@ impl Node { out } + /// As a piece of a mesh-warped image ([`crate::NodeExtras::warp_piece`]), the art whose area is its + /// knockout shape at full coverage: the art of its opacity mask (on and not inverted), or the + /// clipping path of its clip group. `None` for other objects, whose knockout shape is what they + /// paint. + pub fn knockout_outline(&self) -> Option<&Arc> { + let e = self.extras(); + if !e.warp_piece { + return None; + } + if let Some(m) = e.mask.as_deref() { + return (!m.disabled && !m.invert).then_some(&m.art); + } + match &self.kind { + NodeKind::Group { children, clip: true } => children.first(), + _ => None, + } + } + + /// The image a piece of a mesh-warped image draws ([`Self::knockout_outline`]): the piece itself + /// when an opacity mask cuts it, else the image its clip group clips. `None` for other objects. + pub fn warp_piece_image(&self) -> Option<&Node> { + if !self.extras().warp_piece { + return None; + } + match &self.kind { + NodeKind::Image(_) => Some(self), + NodeKind::Group { children, clip: true } => children.get(1).map(|c| &**c).filter(|c| matches!(c.kind, NodeKind::Image(_))), + _ => None, + } + } + /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. /// A layer whose first child is a ruler guide holds no art, so a guide never clips it. pub fn clips(&self) -> bool { diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 335eb6312e..9981514879 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -23,6 +23,7 @@ fn extras_are_written_flat_in_order() { e.shape_mode = crate::ShapeMode::Exclude; e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); e.pixel_aligned = true; + e.warp_piece = true; } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -69,10 +70,12 @@ fn extras_are_written_flat_in_order() { "shape_mode", "envelope_edit_margin", "pixel_aligned", + "warp_piece", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); assert_eq!(v["pixel_aligned"], true); + assert_eq!(v["warp_piece"], true); assert_eq!(serde_json::from_value::(v).unwrap(), n); let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 34d8e99c53..885f9a5853 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -56,6 +56,7 @@ impl Serialize for Node { e.envelope_edit_margin.is_none(), is_default(&e.pixel_aligned), e.color_mask.is_none(), + is_default(&e.warp_piece), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -100,6 +101,7 @@ impl Serialize for Node { field!("envelope_edit_margin", &e.envelope_edit_margin); field!("pixel_aligned", &e.pixel_aligned); field!("color_mask", &e.color_mask); + field!("warp_piece", &e.warp_piece); st.end() } } @@ -245,6 +247,8 @@ pub(super) struct NodeWire { pixel_aligned: bool, #[serde(default)] color_mask: Option>, + #[serde(default)] + warp_piece: bool, } impl From for Node { @@ -274,6 +278,7 @@ impl From for Node { envelope_edit_margin: w.envelope_edit_margin, pixel_aligned: w.pixel_aligned, color_mask: w.color_mask, + warp_piece: w.warp_piece, }; Node { id: w.id, diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 26a11cf733..35de7cce73 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -121,19 +121,22 @@ impl ObjectsOnPath { if a.determinant() < 0.0 && self.rotate != 0.0 { self.rotate = -self.rotate; } - let along = paths.and_then(|(old, new)| { - let s = path_length(new)?.0 / path_length(old)?.0; - (s.is_finite() && s > 0.0).then_some(s) - }); - let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); // A move leaves the distances along the path as they are. let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0; - if !moves && scale.is_finite() && scale > 0.0 { - let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); - let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale)); - let (spacing, start) = moved.unwrap_or((None, None)); - self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); - self.start = start.unwrap_or_else(|| scaled(self.start)); + if !moves { + let measured = paths.and_then(|(old, new)| Some((Measured::new(old)?, Measured::new(new)?))); + let along = measured.as_ref().and_then(|(was, is)| { + let s = is.len / was.len; + (s.is_finite() && s > 0.0).then_some(s) + }); + let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); + if scale.is_finite() && scale > 0.0 { + let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); + let moved = measured.as_ref().and_then(|(was, is)| self.moved_along(a, was, is, scale)); + let (spacing, start) = moved.unwrap_or((None, None)); + self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); + self.start = start.unwrap_or_else(|| scaled(self.start)); + } } let after = tilt(self.rotate); for m in &mut self.members { @@ -150,11 +153,10 @@ impl ObjectsOnPath { /// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along /// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the /// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer - /// than two objects. `None` for a path without length or a start that is not finite. - fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option, Option)> { - let (was, is) = (Spine::new(old)?, Spine::new(new)?); - let (len, closed) = path_length(old)?; - let (new_len, _) = path_length(new)?; + /// than two objects. `None` for a start that is not finite. + fn moved_along(&self, a: Affine, old: &Measured, new: &Measured, scale: f64) -> Option<(Option, Option)> { + let (was, is) = (&old.spine, &new.spine); + let (len, closed, new_len) = (old.len, old.closed, new.len); if !self.start.is_finite() { return None; } @@ -230,10 +232,10 @@ pub fn is_objects_on_path(n: &Node) -> bool { } /// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path -/// (not a guide) whose first subpath has some length. +/// (not a guide) whose first subpath has a length ([`path_length`]). pub fn attachable_path(n: &Node) -> Option<&PathData> { match &n.kind { - NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path), + NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path), _ => None, } } @@ -292,11 +294,27 @@ pub fn upright_frame(n: &Node) -> Affine { } /// The length of `path`'s first subpath and whether it is closed. `None` for a path without -/// length. +/// length, or with a length too large to be finite. pub fn path_length(path: &PathData) -> Option<(f64, bool)> { - let len = Spine::new(path)?.length(); - let closed = path.subpaths.first().is_some_and(|s| s.closed); - (len.is_finite() && len > 1e-9).then_some((len, closed)) + Measured::new(path).map(|m| (m.len, m.closed)) +} + +/// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that +/// one flattening serves both measuring the path and finding points along it. +struct Measured { + spine: Spine, + len: f64, + closed: bool, +} + +impl Measured { + /// `None` for a path without length, or with a length too large to be finite. + fn new(path: &PathData) -> Option { + let spine = Spine::new(path)?; + let len = spine.length(); + let closed = path.subpaths.first().is_some_and(|s| s.closed); + (len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed }) + } } /// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the @@ -341,18 +359,20 @@ fn wrap(s: f64, len: f64) -> f64 { /// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them /// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> { - let spine = Spine::new(path)?; - let (len, closed) = path_length(path)?; + let Measured { spine, len, closed } = Measured::new(path)?; let (start, gap) = layout(len, closed, n, spacing, start); Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) } /// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. +/// `None` for an `i` of `n` or more, without measuring the path. pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> { - let spine = Spine::new(path)?; - let (len, closed) = path_length(path)?; + if i >= n { + return None; + } + let Measured { spine, len, closed } = Measured::new(path)?; let (start, gap) = layout(len, closed, n, spacing, start); - (i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i))) + Some(spine.at_length(nth_length(len, closed, start, gap, i))) } /// The distance along a path of length `len` (closed or not) to object `i`, from `start` and @@ -400,11 +420,15 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { let old = rec.clone(); let NodeKind::Group { children, .. } = &mut g.kind else { return false }; let path_id = rec.path; - let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else { + let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })).cloned() else { g.extras_mut().objects_on_path = None; return true; }; - let path = attachable_path(path).cloned(); + // `stops` measures the path: on a path without length no object moves. + let path = match &path.kind { + NodeKind::Path { path, guide: false, .. } => Some(path), + _ => None, + }; let at: HashMap = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect(); rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id)); let mut listed: HashSet = rec.members.iter().map(|m| m.id).collect(); @@ -414,7 +438,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { } } let mut moved = false; - if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) { + if let Some(stops) = path.and_then(|p| stops(p, rec.members.len(), rec.spacing, rec.start)) { let turn = tilt(rec.rotate); for (m, (p, dir)) in rec.members.iter_mut().zip(stops) { let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue }; @@ -709,6 +733,72 @@ mod tests { assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}"); } + /// What `f` returns, and how many times it flattened a path ([`Spine::new`]). + fn flattenings(f: impl FnOnce() -> T) -> (T, usize) { + crate::blend::SPINES_BUILT.with(|n| n.set(0)); + let out = f(); + (out, crate::blend::SPINES_BUILT.with(|n| n.get())) + } + + #[test] + fn stops_and_stop_flatten_the_path_once() { + let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + for path in [&open, &closed] { + let (all, count) = flattenings(|| stops(path, 4, Some(40.0), 25.0)); + let all = all.unwrap(); + assert_eq!((all.len(), count), (4, 1)); + for (i, s) in all.iter().enumerate() { + assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, i)), (Some(*s), 1), "object {i}"); + } + assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, 4)), (None, 0), "an index past the last object"); + assert_eq!(flattenings(|| stop(path, 0, None, 0.0, 0)), (None, 0), "no objects"); + } + let point = shapes::line(Point::ZERO, Point::ZERO); + assert!(stops(&point, 2, None, 0.0).is_none() && stop(&point, 2, None, 0.0, 0).is_none()); + } + + #[test] + fn transforming_the_group_flattens_each_path_once() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let b = rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)); + let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let stretch = Affine::scale_non_uniform(2.0, 1.0); + let wide = level.transformed(stretch); + let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + let ((), count) = flattenings(|| rec.transform(stretch, Some((&level, &wide)))); + assert_eq!(count, 2, "the path before the transform and after it"); + assert!((rec.start - 20.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}"); + let moved = level.transformed(Affine::translate((5.0, 5.0))); + let ((), count) = flattenings(|| rec.transform(Affine::translate((5.0, 5.0)), Some((&level, &moved)))); + assert_eq!(count, 0, "a move leaves the distances along the path as they are"); + } + + #[test] + fn placing_the_objects_flattens_the_path_once() { + let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default()); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0))); + let empty = crate::Document::new(1.0, 1.0); + let symbols = empty.symbol_boxes(); + assert_eq!(flattenings(|| place(&mut g, &symbols)), (true, 1)); + let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; + assert!((x(NodeId(2))).abs() < 1e-9 && (x(NodeId(3)) - 300.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); + } + + /// A path whose length adds up past the largest finite number cannot be measured, so nothing + /// attaches to it. + #[test] + fn a_path_too_long_to_measure_is_not_attachable() { + let huge = Node::path(NodeId(1), shapes::line(Point::new(-1e308, 0.0), Point::new(1e308, 0.0)), Default::default()); + let NodeKind::Path { path, .. } = &huge.kind else { panic!("a path") }; + assert!(path_length(path).is_none() && stops(path, 2, None, 0.0).is_none()); + assert!(attachable_path(&huge).is_none()); + let square = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + assert!(attachable_path(&square).is_some()); + } + #[test] fn a_group_without_its_path_is_a_plain_group() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 0812095826..c451fea7be 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -194,6 +194,27 @@ impl ImageBlob { .collect(); Some((w, h, cover)) } + + /// The pixels as straight RGBA, row by row from the top, with the width and the height; + /// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`]. + pub fn rgba(&self) -> Option<(u32, u32, Vec)> { + let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?; + let (w, h) = img.dimensions(); + Some((w, h, img.into_raw())) + } + + /// The width and height in pixels the image's file says it has; `None` for data that isn't an + /// image the decoder reads. Reads only its header. + pub fn pixel_size(&self) -> Option<(u32, u32)> { + image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok() + } + + /// Whether the image's file is a PNG that stores palette indices (an indexed-color image). + /// Reads only its header. + pub fn indexed_png(&self) -> bool { + // The signature, then the IHDR chunk: length, type, width, height, bit depth, color type. + self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3) + } } impl ImageBlob { @@ -266,6 +287,20 @@ impl ImageBlob { Some(out) } + /// Whether any pixel of the image is less than fully opaque. `false` when the image can't be + /// decoded. The last image decoded is kept, as for [`Self::sample`]. + pub fn has_transparency(&self) -> bool { + self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255)) + } + + /// The alpha of each pixel, row by row from the top: the width, the height and one byte a + /// pixel. `None` when the image can't be decoded. The last image decoded is kept, as for + /// [`Self::sample`]. + pub fn alpha(&self) -> Option<(u32, u32, Vec)> { + let img = self.decoded()?; + Some((img.width(), img.height(), img.pixels().map(|p| p.0[3]).collect())) + } + /// The decoded pixels (the last image decoded is kept for the next sample). fn decoded(&self) -> Option> { thread_local! { diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index b77f804de7..7e42147443 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -83,15 +83,17 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext /// The geometry `g` with the own effects of `item` (one of the fills and strokes `ctx` is the /// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient -/// moved along with them. +/// moved along with them and the Transform effects the geometry pass left out hidden +/// ([`crate::Carry::hide_skipped`]). fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { let (ig, carry) = apply(item_effects(item), g, &ctx.item(item)); let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() }; - if !carry.moves(&ap) { - return (ig, carried.clone()); + if carry.moves(&ap) { + carry.apply(&mut ap); } - carry.apply(&mut ap); - (ig, ap.items.pop().unwrap_or_else(|| carried.clone())) + let mut out = ap.items.pop().unwrap_or_else(|| carried.clone()); + carry.hide_skipped(out.effects_mut()); + (ig, out) } /// A path node of `kind`'s flavour (compound when the result has several subpaths). @@ -470,6 +472,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } + carry.hide_skipped(&mut m.appearance.effects); // The raster effects of its fills and strokes follow its Transform effects, as on the canvas. crate::raster::follow_items(&mut m); m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 444e814d86..a3e31f1842 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -1,7 +1,8 @@ //! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine //! and several shapes united, so the renderer and the SVG and PDF writers clip alike. -use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind}; use vectorcraft_geom::{BezPath, FillRule, PathData}; /// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their @@ -11,6 +12,209 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { clip.clip_outline(Some(&outline_text), &unite) } +/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque +/// in `color`, which the SVG and PDF writers put in their knockout masks: for a clip group, a +/// rectangle clipped by the same clipping path (so a viewer covers the same pixels of its edges as +/// when it clips the piece), else the path of its opacity mask's art, filled. `None` for other +/// objects and for a piece with nothing to clip by. +pub fn knockout_silhouette(n: &Node, color: Color) -> Option { + let outline = n.knockout_outline()?; + let (bp, rule) = clip_outline(outline)?; + let paint = Appearance::basic(Paint::solid(color), Paint::None, 0.0); + if n.clips() && n.extras().mask.is_none() { + let cover = Node::path(n.id, vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Shape::bounding_box(&bp).inflate(1.0, 1.0)), paint); + let children = vec![outline.clone(), std::sync::Arc::new(cover)]; + return Some(Node::new(n.id, NodeKind::Group { children, clip: true })); + } + let mut p = Node::path(n.id, PathData::from_bezpath(&bp), paint); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + Some(p) +} + +/// Whether any of `pieces`, pieces of a mesh-warped image ([`Node::warp_piece_image`]), paints less +/// than opaque anywhere inside its triangle: it or its image is partly transparent, a color mask +/// clears some of its colors, a colorize paint draws its light pixels partly transparent, or the +/// pixels of its image (looked up in `doc`) are. Each distinct +/// image's pixels are scanned once. Pieces of an opaque image overlap without showing it, so the SVG +/// and PDF writers draw them one over another without masks. +pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool { + let mut scanned: Vec<*const vectorcraft_doc::ImageBlob> = vec![]; + for n in pieces { + let Some(image) = n.warp_piece_image() else { continue }; + let NodeKind::Image(im) = &image.kind else { continue }; + let e = image.extras(); + if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() { + return true; + } + let Some(blob) = doc.image_blob(im) else { continue }; + if scanned.contains(&std::ptr::from_ref(blob)) { + continue; + } + scanned.push(std::ptr::from_ref(blob)); + if blob.has_transparency() { + return true; + } + } + false +} + +/// Which overlaps of the pieces of a mesh-warped image ([`Node::warp_piece_image`]) can show +/// through. Where an upper piece overlaps a lower one only over fully opaque pixels of the image, +/// the SVG and PDF writers draw the upper piece over the lower one without a knockout mask for it: +/// a mask there would make the opaque pixels on the upper piece's antialiased edge lighter. +pub struct WarpSeams { + pieces: Vec>, + alphas: Vec<(u32, u32, Vec)>, +} + +/// A piece of a mesh-warped image whose pixels decide whether its overlaps show through. +struct SeamPiece { + /// The piece's triangle, in document space. + triangle: [vectorcraft_geom::Point; 3], + /// Maps document space to the pixels of its image as decoded. + to_pixels: vectorcraft_geom::Affine, + /// The index of its image's alpha in [`WarpSeams::alphas`]. + alpha: usize, +} + +/// How far from an overlap, in image pixels, a less than opaque pixel makes the overlap show +/// through: a smoothed image blends each pixel with its neighbors. +const SEAM_MARGIN: f64 = 2.0; + +impl WarpSeams { + /// The overlaps of `pieces` (pieces of a mesh-warped image, in paint order), with the pixels of + /// their images looked up in `doc`. Each distinct image is decoded once. + pub fn new(doc: &Document, pieces: &[&Node]) -> Self { + let mut images: Vec<*const vectorcraft_doc::ImageBlob> = vec![]; + let mut alphas = vec![]; + let pieces = pieces + .iter() + .map(|n| { + let image = n.warp_piece_image()?; + let NodeKind::Image(im) = &image.kind else { return None }; + let e = image.extras(); + if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() { + return None; + } + let triangle = triangle(n.knockout_outline()?)?; + let blob = doc.image_blob(im)?; + let alpha = match images.iter().position(|b| std::ptr::eq(*b, blob)) { + Some(i) => i, + None => { + alphas.push(blob.alpha()?); + images.push(std::ptr::from_ref(blob)); + alphas.len() - 1 + } + }; + let (w, h, _) = &alphas[alpha]; + if im.width == 0 || im.height == 0 || im.xf.determinant().abs() < 1e-12 { + return None; + } + let scale = vectorcraft_geom::Affine::scale_non_uniform(f64::from(*w) / f64::from(im.width), f64::from(*h) / f64::from(im.height)); + Some(SeamPiece { triangle, to_pixels: scale * im.xf.inverse(), alpha }) + }) + .collect(); + WarpSeams { pieces, alphas } + } + + /// Whether piece `above` (an index into the pieces) drawn over piece `below` can show the + /// lower piece through where they overlap: either piece or its image is partly transparent, a + /// color mask or a colorize paint changes its alpha, or a pixel of either image within + /// [`SEAM_MARGIN`] of the overlap is less than opaque. `true` for a piece that is not a + /// triangle of an image that can be decoded; `false` when the triangles do not overlap. + pub fn shows_through(&self, below: usize, above: usize) -> bool { + let (Some(Some(a)), Some(Some(b))) = (self.pieces.get(below), self.pieces.get(above)) else { return true }; + let overlap = convex_intersection(&a.triangle, &b.triangle); + overlap.len() >= 3 && [a, b].into_iter().any(|p| self.translucent_near(p, &overlap)) + } + + /// Whether a pixel of piece `p`'s image within [`SEAM_MARGIN`] of the convex polygon `overlap` + /// (document space) is less than opaque. + fn translucent_near(&self, p: &SeamPiece, overlap: &[vectorcraft_geom::Point]) -> bool { + let Some((w, h, alpha)) = self.alphas.get(p.alpha) else { return true }; + let poly: Vec = overlap.iter().map(|q| p.to_pixels * *q).collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for q in &poly { + (x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y)); + } + let span = |lo: f64, hi: f64, n: u32| { + let lo = (lo - SEAM_MARGIN).floor().clamp(0.0, f64::from(n)) as u32; + let hi = (hi + SEAM_MARGIN).ceil().clamp(0.0, f64::from(n)) as u32; + lo..hi + }; + let orientation = signed_area(&poly).signum(); + for y in span(y0, y1, *h) { + for x in span(x0, x1, *w) { + let c = vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + let near = poly.iter().zip(poly.iter().cycle().skip(1)).all(|(s, e)| { + let edge = *e - *s; + orientation * edge.cross(c - *s) >= -SEAM_MARGIN * edge.hypot() + }); + let i = y as usize * *w as usize + x as usize; + if near && alpha.get(i).is_none_or(|a| *a < 255) { + return true; + } + } + } + false + } +} + +/// The three corners of the straight-sided triangle `outline` draws, in document space. +fn triangle(outline: &Node) -> Option<[vectorcraft_geom::Point; 3]> { + let (bp, _) = clip_outline(outline)?; + let mut points = vec![]; + for el in bp.elements() { + match el { + vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if points.is_empty() || points.last() != Some(p) => { + points.push(*p) + } + vectorcraft_geom::PathEl::MoveTo(_) | vectorcraft_geom::PathEl::LineTo(_) | vectorcraft_geom::PathEl::ClosePath => {} + _ => return None, + } + } + if points.len() == 4 && points.first() == points.last() { + points.pop(); + } + let [a, b, c] = points.as_slice() else { return None }; + Some([*a, *b, *c]) +} + +/// Twice the signed area of polygon `poly`. +fn signed_area(poly: &[vectorcraft_geom::Point]) -> f64 { + poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.to_vec2().cross(b.to_vec2())).sum() +} + +/// The intersection of convex polygons `subject` and `clip`, as a convex polygon (empty when they +/// do not overlap). +fn convex_intersection(subject: &[vectorcraft_geom::Point], clip: &[vectorcraft_geom::Point]) -> Vec { + let orientation = signed_area(clip).signum(); + if orientation == 0.0 { + return vec![]; + } + let mut out = subject.to_vec(); + for (s, e) in clip.iter().zip(clip.iter().cycle().skip(1)) { + let edge = *e - *s; + let side = |p: vectorcraft_geom::Point| orientation * edge.cross(p - *s); + let input = std::mem::take(&mut out); + for (p, q) in input.iter().zip(input.iter().cycle().skip(1)) { + let (sp, sq) = (side(*p), side(*q)); + if sp >= 0.0 { + out.push(*p); + } + if (sp >= 0.0) != (sq >= 0.0) { + out.push(*p + (*q - *p) * (sp / (sp - sq))); + } + } + if out.is_empty() { + break; + } + } + out +} + /// Text as one path of its glyph outlines, in document space. fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index a2e5478b28..39924835fd 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -200,8 +200,12 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R /// it unevenly (a map after which a refit would not match the mapped gradient): it is fixed to its /// fit on the geometry that effect takes, and then follows that effect and the ones after it as a /// placed gradient does. +/// +/// It also lists the visible Transform effects that move the art once and that the pass left out, +/// their results past the budget, by their index in the effect list it was given: the raster +/// effects before such an effect stay as they are, as the art does ([`Self::hide_skipped`]). #[derive(Clone, Debug, Default, PartialEq)] -pub struct Carry(Vec); +pub struct Carry(Vec, Vec); /// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit /// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map @@ -265,7 +269,7 @@ impl Carry { _ => None, }, }; - Carry(step.into_iter().collect()) + Carry(step.into_iter().collect(), vec![]) } /// Does it leave gradients where they are? @@ -310,6 +314,30 @@ impl Carry { pub(crate) fn push(&mut self, more: Carry) { self.0.extend(more.0); + self.1.extend(more.1); + } + + /// Did the pass leave out a Transform effect that moves the art once ([`Self::hide_skipped`])? + pub fn skipped(&self) -> bool { + !self.1.is_empty() + } + + /// Hide each Transform effect of `effects`, the list the pass was given, that it left out (its + /// result past the budget): the art stays where it was, so the raster effects listed before it + /// do not follow it ([`crate::raster::followed`]). Other effects stay as they are. + pub fn hide_skipped(&self, effects: &mut [Effect]) { + for &i in &self.1 { + if let Some(e) = effects.get_mut(i).filter(|e| e.id == "distort.transform") { + e.visible = false; + } + } + } + + /// Note that the Transform effect at index `i` of the pass's list was left out. + pub(crate) fn skip(&mut self, i: usize) { + if !self.1.contains(&i) { + self.1.push(i); + } } } @@ -354,6 +382,13 @@ pub(crate) fn apply_geometry_carrying_within( c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed)); } cur = next; + } else if let Some(c) = carry.as_deref_mut() + && e.id == "distort.transform" + && !crate::raster::holds_back(e) + && let Some(i) = effects.iter().position(|x| std::ptr::eq(x, e)) + { + // The raster effects before it stay as the art does. + c.skip(i); } } cur diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index a6581fa449..bb2f605a91 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -261,8 +261,17 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { carry.apply(&mut m.appearance); } // So do the raster effects of its fills and strokes, by `n`'s Transform effects - // ([`crate::follow_items`]). - crate::raster::follow_items_of(&mut m, &n.appearance.effects); + // ([`crate::follow_items`]) that moved the outline. + let mut owner = std::borrow::Cow::Borrowed(n.appearance.effects.as_slice()); + if carry.skipped() { + carry.hide_skipped(owner.to_mut()); + if m.appearance.effects == n.appearance.effects { + let mut ap = (*m.appearance).clone(); + ap.effects = owner.to_vec(); + m.appearance = ap.into(); + } + } + crate::raster::follow_items_of(&mut m, &owner); } without_geometry_effects(&mut m); m diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ac61746266..6316a7a273 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use adjust::{ is_channel, }; pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; -pub use clip::clip_outline; +pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 9a352189d1..8592776a95 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -96,6 +96,18 @@ pub(crate) fn rgb_gradient_art( composed_art(id, path, paint, Ramp::Rgb(light), 0.75) } +/// The narrow stroke of a facet's own `paint`, `width` wide, that closes the hairline seam between +/// it and its neighbors where each is antialiased alone. Its joins are round: a facet seen almost +/// edge-on is a sharp sliver, and a mitered seam would reach far past its tip as a thorn, where a +/// round one reaches no more than half its width past the facet. Revolve's meets() counts on a +/// stroked seam reaching no more than half its width; faces drawn as clipped groups (whose clip, +/// in `composed_art`, already outlines the seam with round joins) are measured by their box. +pub(crate) fn seam_stroke(paint: Paint, width: f64) -> vectorcraft_doc::StrokeLayer { + let mut edge = vectorcraft_doc::StrokeLayer::new(paint, width); + edge.join = vectorcraft_doc::LineJoin::Round; + edge +} + /// Smooth Inflate triangles keep the seam within their narrow projected footprint. pub(crate) fn facet_art( id: vectorcraft_doc::NodeId, @@ -109,8 +121,7 @@ pub(crate) fn facet_art( let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] }; if let Some(color) = paint.color() { let paint = smooth_paint(light, color); - let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), seam); return vectorcraft_doc::Node::path( id, path.clone(), @@ -131,7 +142,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; - use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer}; + use vectorcraft_doc::{Appearance, Node, NodeKind}; use vectorcraft_geom::{BezPath, PathData, Rect, shapes}; let mut boundary = BezPath::new(); @@ -163,8 +174,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t } else { Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) }; - let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); Arc::new(Node::path( id, face.path(), diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 89a258bb07..8c829d9e0b 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -895,9 +895,16 @@ pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option return None; } let mut art = one_piece(n); - reshape_leaves(&mut art, &fx, &n.appearance.effects, doc); + let carry = reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc); transform_items(&mut art, doc); - Some(dressed(art, n)) + let mut art = dressed(art, n); + // The raster effects before a Transform effect the path did not take stay as the path does. + if carry.skipped() { + let mut ran = n.appearance.effects.clone(); + carry.hide_skipped(&mut ran); + art.appearance.effects = crate::raster::without_geometry(&ran); + } + Some(art) } /// Map every path under `art` (and its gradients), and every path in the art of the linked opacity @@ -997,7 +1004,8 @@ fn hoist_copies(art: &mut Node) { /// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect /// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]), /// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). -/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect +/// Returns how gradients that paint the art as a whole follow the effects, and the Transform +/// effects that nothing in the art took past the budget ([`crate::Carry::skipped`]). A deferred live-effect /// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when /// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of /// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the @@ -1053,6 +1061,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget used = cost; continue; } + // A Transform effect that moves the art once and that nothing in it took (past the budget) + // is left out ([`crate::Carry::hide_skipped`]), listed by its index in `owner`. + if ran.kept + && !ran.took + && e.id == "distort.transform" + && !crate::raster::holds_back(e) + && let Some(i) = owner.iter().position(|x| std::ptr::eq(x, *e)) + { + whole.skip(i); + } if let Some(carry) = ran.whole { whole.push(carry); } @@ -1084,6 +1102,8 @@ struct Step<'a> { struct Ran { /// A path in the art kept its shape (past the budget). kept: bool, + /// A path, image or symbol instance in the art took the effect. + took: bool, /// The linked opacity mask of an object in the art stayed as it was while the paths it masks /// took the effect, or could have ([`Step::reshape_masks`]). masks: bool, @@ -1123,7 +1143,7 @@ impl Step<'_> { depth: usize, ) -> Ran { let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed); - let stayed = Ran { kept: false, masks: true, short: false, whole: None }; + let stayed = Ran { kept: false, took: false, masks: true, short: false, whole: None }; if let Some(plugin) = painted_plugin(e) { if own || has_linked_mask(art, false) { return stayed; @@ -1137,7 +1157,7 @@ impl Step<'_> { *used = used.minus(before).plus(made); } }); - return Ran { kept: false, masks: false, short: false, whole: None }; + return Ran { kept: false, took: true, masks: false, short: false, whole: None }; } // A Warp effect that bends images ([`mark_warped_images`]) bends an image's pixels, not the // art of its linked mask. @@ -1190,7 +1210,7 @@ impl Step<'_> { // ([`transform_placed`]): a mask's art that holds one can't take another effect as the art // it masks does. if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) { - return Ran { kept: false, masks: true, short: false, whole: None }; + return Ran { kept: false, took: false, masks: true, short: false, whole: None }; } // Art of several objects is copied whole, so the copies don't interleave member by member. // The copies of its members carry their linked masks, moved with them. @@ -1200,7 +1220,7 @@ impl Step<'_> { { if !own { *used = copy_art(art, m, copies, copy_scaling(options), max); - return Ran { kept: false, masks: false, short: false, whole: None }; + return Ran { kept: false, took: true, masks: false, short: false, whole: None }; } // A mask's art: its copies keep to what the rest of the art leaves of `max`, and the // linked mask of the art itself takes them too. That mask's art stays in `used`, as @@ -1212,7 +1232,7 @@ impl Step<'_> { let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies); let none = HashSet::new(); let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth); - return Ran { kept: false, masks, short, whole: None }; + return Ran { kept: false, took: true, masks, short, whole: None }; } // A shortfall in the copies matters for the objects a linked mask above them covers, which // the mask would take a different number of copies than, and anywhere in a mask's art. @@ -1223,6 +1243,7 @@ impl Step<'_> { } let counts = |id: NodeId| watch && (own || covered.contains(&id)); let mut short = false; + let mut took = false; for_each_leaf(art, &mut |leaf| { let at = leaf_at; leaf_at += 1; @@ -1247,6 +1268,7 @@ impl Step<'_> { if options.reach_appearance() { let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, raster_map.is_some(), None); *used = used.minus(before).plus(made); + took |= moved; if moved { // With Transform Objects off the path stays, and its mask takes the // effect's stroke and pattern changes as its own art. @@ -1263,6 +1285,7 @@ impl Step<'_> { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; let made = copy_leaf(leaf, &path, m, copies, scaling, room, None); *used = used.minus(before).plus(made); + took = true; moved_ids.insert(leaf.id); short |= counts(leaf.id) && copied(leaf) <= copies; return; @@ -1274,6 +1297,7 @@ impl Step<'_> { kept_leaves.insert(at); return; }; + took = true; // A Transform effect's copies of a path are subpaths of it, each with the path's anchors. if counts(leaf.id) && let Some((_, copies, _, _)) = transform @@ -1315,6 +1339,8 @@ impl Step<'_> { *art = Node::group(id, vec![Arc::new(one)]); } let placed = if watch { masked_placed(art, own) } else { 0 }; + // Images and symbol instances take the effect whatever the budget. + took = took || placed_count(art) > 0; // Scale Strokes & Effects leaves the raster effects that follow the effect to // [`follow_art`]. let mut scaling = copy_scaling(options); @@ -1348,7 +1374,7 @@ impl Step<'_> { } let drawn = warped.as_deref().map_or_else(|| art.geometric_bounds(), |w| w.bounds(art)); let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), drawn, seed)); - Ran { kept: !kept_ids.is_empty(), masks, short, whole } + Ran { kept: !kept_ids.is_empty(), took, masks, short, whole } } /// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path, @@ -2074,9 +2100,14 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt // effects before this one back from every Transform effect ([`crate::raster::followed`]). let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back); // The effects before the Transform effect that it keeps, each raster effect following the - // geometry effects before it ([`crate::raster::without_geometry`]) unless held back. + // geometry effects before it that moved the geometry ([`crate::raster::without_geometry`], + // [`crate::Carry::hide_skipped`]) unless held back. + let mut ran = std::borrow::Cow::Borrowed(before); + if carry.skipped() { + carry.hide_skipped(ran.to_mut()); + } let kept_before: Vec = - if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) }; + if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(&ran) }; if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); @@ -2490,7 +2521,10 @@ fn piece_stage(leaf: &mut Node, e: &Effect, at: &PieceStage, (appearance, index) carry.apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); - follow_piece(leaf, was, at.from, e); + // A Transform effect left out (past the budget) leaves the path and its raster effects as they were. + if !carry.skipped() { + follow_piece(leaf, was, at.from, e); + } Some(made) } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 9281a802bf..74b23340cd 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; @@ -494,10 +494,10 @@ fn surface( .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH); - // Acute face corners must stay within the overlap width. Miter joins - // amplify this seam stroke into visible spikes along the silhouette. - edge.join = LineJoin::Round; + // Acute face corners must stay within the overlap width: meets() counts on a + // stroked seam reaching no more than half its width; faces drawn as clipped + // groups are measured by their box. + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), SEAM_WIDTH); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 9b364c3d3a..2642fca878 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; use vectorcraft_pathops::BoolOp; use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; @@ -530,7 +530,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group.blend = blend; return group; } - // Preserve existing white-light paints, alpha composition and seam joins exactly. + // Preserve existing white-light paints and alpha composition exactly. let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) }; let paint = &opaque; let children = facets @@ -551,10 +551,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), seam); - if colored { - edge.join = vectorcraft_doc::LineJoin::Round; - } + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), seam); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 919de831ce..23d8d0ffad 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node}; use vectorcraft_geom::Rect; use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light}; @@ -207,8 +207,8 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 .into_iter() .map(|face| { if wire { - let mut edge = StrokeLayer::new(paint.clone(), WIRE); - edge.join = vectorcraft_doc::LineJoin::Round; + // A wire edge is drawn as a seam is: a round-joined stroke of the paint. + let edge = crate::lighting::seam_stroke(paint.clone(), WIRE); return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() })); } let (tone, spec) = shading.tone(face.normal, &lights); @@ -218,18 +218,23 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 // Unshaded (No Shading, no shading color, or a face in full light) with no // highlight: the paint itself, its color space and swatch kept. Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => { - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))] + vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ] } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); - vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))] + vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] } // Gradients and patterns keep their paint, darkened by a layer of the shading color // and lightened by one of white. As with the other 3D effects' pattern shadow, the // layers cover the whole face, transparent pattern cells included. None => { - let mut items = - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]; + let mut items = vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ]; for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] { if let Some(color) = color.filter(|_| share > 0.0) { let mut layer = FillLayer::new(Paint::solid(color)); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 5126b76660..c825159dda 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -1293,6 +1293,9 @@ fn inflate_reads_twist_taper_and_cap() { assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true)); } +/// Inflate under the legacy white light keeps the polygon faces it had before the colored-light +/// batching (each face a path of its own, not a channel group) and its seam's 0.05–0.75 pt range; +/// its seams' joins are round, as every 3D seam's. #[test] fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { let mut n = square_node(); @@ -1305,7 +1308,9 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { assert!(matches!(face.kind, NodeKind::Path { .. })); let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") }; assert!((0.05..=0.75).contains(&stroke.width)); - assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter); + // Round, as every seam is: a facet seen almost edge-on is a sliver, and a mitered seam + // reached past the solid as a thorn. + assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Round); } // A tiny shape exercises the lower bound, which otherwise disappears at grazing angles. n.transform(Affine::scale(0.001), false); @@ -1697,3 +1702,248 @@ fn classic_rotate_parameters_are_checked() { assert!(err.starts_with(says), "{p}: {err}"); } } + +/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate, Extrude & +/// Bevel (Classic, each of its shading branches) and Rotate (Classic, its No Shading and Diffuse +/// surfaces and a gradient), a face seen almost edge-on (a sharp sliver) has +/// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its +/// face more than half its width as it is outlined (a mitered seam would reach past the solid as a +/// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a +/// corner a miter would spike from. +#[test] +fn a_faces_seam_stays_within_half_its_width_of_the_face() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); + // Does `path` have a convex corner sharper than 45° that a miter with `limit` would spike from + // (one sharper than 2·asin(1/limit) is bevelled instead, so it can't thorn; a reflex corner's + // miter points inward)? An open subpath's ends are no corners, and a closing anchor repeating + // the first is one corner. + fn acute(path: &PathData, limit: f64) -> bool { + let floor = 2.0 * (1.0 / limit).asin(); + path.subpaths.iter().any(|sp| { + let mut pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); + pts.dedup_by(|a, b| a.distance(*b) < 1e-9); + if sp.closed && pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { + pts.pop(); + } + let n = pts.len(); + let corners: Vec = if sp.closed { (0..n).collect() } else { (1..n.saturating_sub(1)).collect() }; + // Which way the subpath turns as a whole: a convex corner turns the same way. + let area: f64 = (0..n).map(|k| pts[k].to_vec2().cross(pts[(k + 1) % n].to_vec2())).sum(); + n >= 3 + && corners.into_iter().any(|k| { + let (a, b, c) = (pts[(k + n - 1) % n], pts[k], pts[(k + 1) % n]); + let (u, v) = (a - b, c - b); + let convex = !sp.closed || (b - a).cross(c - b) * area > 0.0; + let angle = (u.dot(v) / (u.hypot() * v.hypot())).clamp(-1.0, 1.0).acos(); + convex && angle > floor && angle < std::f64::consts::FRAC_PI_4 + }) + }) + } + // Views that make slivers whose corners a miter would spike from, in every case. + let views = [(-35.0, 25.0), (-85.0, 15.0), (60.0, 60.0)]; + let mut cases = vec![]; + for &(x, y) in &views { + let view = json!({"rotationX": x, "rotationY": y, "rotationZ": 0, "depth": 60}); + // Rotate (Classic) takes no depth. + let flat = json!({"rotationX": x, "rotationY": y, "rotationZ": 0}); + let with = |extra: Value| { + let mut p = view.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), None)); + // A gradient under a white light: its paint and the shadow fill over it each have a seam. + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({})), None)); + // Under colored lights: one, two, and a gradient's paint. + let one = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}]}); + let two = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]}); + cases.push((EXTRUDE, x, y, with(one.clone()), None)); + cases.push((EXTRUDE, x, y, with(two.clone()), None)); + cases.push((EXTRUDE, x, y, with(one), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({"lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]})), None)); + // Rotate turns the art flat: a thin triangle, some corner of which stays sharp enough at + // every view; under a white light and a colored one. + cases.push((ROTATE, x, y, with(json!({})), None)); + cases.push((ROTATE, x, y, with(json!({"lights": [{"color": "#ff8080"}]})), None)); + // Classic: shaded, unshaded (No Shading) and a gradient's paint kept under its shading + // layers each make their seam their own way. + let bevel = json!({"bevel": "classic", "bevelHeight": 1.5}); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel.clone()), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(json!({"bevel": "classic", "bevelHeight": 1.5, "surface": "none"})), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel), Some(gradient.clone()))); + // Rotate (Classic) draws its plane through Classic's surfaces: No Shading and Diffuse. + for surface in ["none", "diffuse"] { + let mut p = flat.clone(); + p["surface"] = json!(surface); + cases.push((CLASSIC_ROTATE, x, y, p, None)); + } + let mut p = flat.clone(); + p["surface"] = json!("diffuse"); + cases.push((CLASSIC_ROTATE, x, y, p, Some(gradient.clone()))); + } + // Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is. + let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 }; + // Rotate (Classic)'s one plane is shaded at some views, not at every one (the light can leave + // it as its paint), so its Diffuse case is held to a darkened face at one view at least. + let mut rotate_shaded = false; + for (id, x, y, params, paint) in cases { + let mut n = if id == ROTATE || id == CLASSIC_ROTATE { + let mut b = BezPath::new(); + b.move_to((100.0, 90.0)); + b.line_to((160.0, 110.0)); + b.line_to((100.0, 130.0)); + b.close_path(); + stroked(&b) + } else { + stroked_rect(4.0) + }; + let unshaded = params["surface"] == "none"; + for (k, item) in n.appearance.items.iter_mut().enumerate() { + match &paint { + Some(paint) => *item.paint_mut() = paint.clone(), + None if (id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE) && k == 0 => *item.paint_mut() = brick.clone(), + None => {} + } + } + // Each solid on its own (the fill's, then the stroke's), so every check holds for each. + for keep in 0..n.appearance.items.len() { + let mut solo = n.clone(); + let item = solo.appearance.items.remove(keep); + solo.appearance.items = vec![item.clone()]; + let source = item.paint().clone(); + let what = format!("{id} {params} gradient {} view ({x}, {y}) solid {keep}", paint.is_some()); + solo.appearance.effects.push(fx(id, params.clone())); + let art = solid_art(&solo).unwrap(); + let mut seamed = vec![]; + art.walk(&mut |face| { + if let Some(path) = face.path_data() + && face.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) + { + seamed.push((path.clone(), face.appearance.items.clone())); + } + }); + // Each Classic case (Extrude & Bevel and Rotate) reaches its own branch: an unshaded face + // keeps the paint itself (its swatch and color space too), a shaded one is the paint + // darkened, and a gradient's face keeps the gradient. + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { + let fills: Vec<&Paint> = seamed + .iter() + .filter_map(|(_, items)| match items.first() { + Some(AppearanceItem::Fill(f)) => Some(&f.paint), + _ => None, + }) + .collect(); + assert!(!fills.is_empty(), "{what}: faces"); + if paint.is_some() || unshaded { + assert!(fills.iter().all(|f| **f == source), "{what}: every face keeps its paint as it is"); + } else { + let [r, g, b] = source.color().map(|c| c.to_rgb()).unwrap_or([0.0; 3]); + let darkened = fills.iter().any(|f| match f { + Paint::Solid { color, swatch: None, .. } => { + let [fr, fg, fb] = color.to_rgb(); + fr <= r + 1e-4 && fg <= g + 1e-4 && fb <= b + 1e-4 && fr + fg + fb < r + g + b - 1e-3 + } + _ => false, + }); + if id == CLASSIC_ROTATE { + rotate_shaded |= darkened; + } else { + assert!(darkened, "{what}: some face shaded darker than its paint"); + } + } + } + // Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some + // face a miter would spike from. + let (mut checked, mut sliver): (usize, Vec) = (0, vec![]); + for (path, items) in &seamed { + let outline = path.to_bezpath(); + let acute_here = items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => Some(acute(path, s.miter_limit)), + _ => None, + }); + let mut which = 0; + for item in items { + // A seam paints something (a stroke of no width or no paint draws nothing). + let AppearanceItem::Stroke(seam) = item else { continue }; + if seam.width <= 0.0 || matches!(seam.paint, Paint::None) { + continue; + } + if sliver.len() <= which { + sliver.resize(which + 1, false); + } + sliver[which] |= acute_here.unwrap_or(false); + which += 1; + checked += 1; + assert_eq!(seam.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + // The seam outlined as it is drawn: no point of it farther from the face than + // half its width. + let drawn = crate::stroke::line_outline(&outline, seam, seam.width, seam.width * 1e-3); + let (mut farthest, mut points): (f64, usize) = (0.0, 0); + kurbo::flatten(drawn.iter(), seam.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + points += 1; + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(points > 0, "{what}: a seam outlined as nothing"); + assert!(farthest <= seam.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", seam.width); + } + } + assert!(checked > 0, "{what}: seams checked"); + for (k, s) in sliver.iter().enumerate() { + assert!(s, "{what}: no face with an acute corner carries seam {k}"); + } + } + } + assert!(rotate_shaded, "Rotate (Classic), Diffuse: no view shades its plane darker than its paint"); +} + +/// Revolve (Classic) draws its faces through Classic's surfaces: on the plastic, diffuse and +/// unshaded surfaces, and with a gradient, every face's seam has round joins and reaches no more +/// than half its width past its face as it is outlined (the reach Revolve's overlap test counts on). +#[test] +fn classic_revolves_seams_are_round() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + let cases = [("plastic", Paint::solid(BLUE)), ("diffuse", Paint::solid(BLUE)), ("none", Paint::solid(BLUE)), ("plastic", gradient)]; + for (surface, paint) in cases { + let what = format!("{surface} gradient {}", matches!(paint, Paint::Gradient(_))); + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface}))); + let art = crate::revolve_art(&n).unwrap(); + let mut seams = 0; + art.walk(&mut |face| { + let Some(path) = face.path_data() else { return }; + for item in &face.appearance.items { + let AppearanceItem::Stroke(s) = item else { continue }; + if s.width <= 0.0 { + continue; + } + seams += 1; + assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + let drawn = crate::stroke::line_outline(&path.to_bezpath(), s, s.width, s.width * 1e-3); + let mut farthest: f64 = 0.0; + kurbo::flatten(drawn.iter(), s.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(farthest <= s.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", s.width); + } + }); + assert!(seams > 0, "{what}: seams checked"); + } +} diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4400555d2b..7e7d2e1595 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -1,6 +1,6 @@ use serde_json::json; use vectorcraft_doc::color::Color; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath}; use super::*; diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index aead8b72ce..a610ae7c71 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -4,9 +4,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; -use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, shapes}; -use crate::{RasterFx, bake_document, expand_outlined, raster_effects}; +use crate::geometry::{Budget, MAX_EFFECT_LINES, apply_geometry_carrying_within}; +use crate::{Carry, GeomContext, RasterFx, apply_geometry_carrying, bake_document, expand_outlined, raster_effects}; fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } @@ -652,3 +653,191 @@ fn a_groups_transform_moves_its_members_fills_shadows_beside_their_own_transform } } } + +/// A closed path of `n` curves along the x axis, 2 pt apart, each drawn with 14 lines at 1 px/pt +/// and with 425 once scaled 1000 times ([`crate::util::line_estimate`]). +fn wavy(n: usize) -> PathData { + let anchors = (0..=n) + .map(|i| { + let x = 2.0 * i as f64; + Anchor::with_handles(Point::new(x, 0.0), Point::new(x - 0.5, -20.0), Point::new(x + 0.5, 20.0)) + }) + .collect(); + PathData::single(SubPath::new(anchors, true)) +} + +/// A red path `id` along `path` with object effects `effects`. +fn path_node(id: u64, path: PathData, effects: Vec) -> Node { + let mut n = Node::path(NodeId(id), path, Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)); + n.appearance.effects = effects; + n +} + +/// A Transform effect scaling 1000 times. +fn thousandfold() -> Effect { + transform(json!({"scaleH": 100000, "scaleV": 100000})) +} + +/// `n` baked on its own ([`bake_document`]). +fn baked(n: Node) -> Node { + let id = n.id; + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + bake_document(&d).unwrap().node(id).unwrap().clone() +} + +/// A Transform effect whose result passes the 8 million lines of the effect budget is left out, so +/// the art stays where it is, and the shadow before it keeps its offset and blur with it: drawn, +/// baked for export, and once shown after baking when it is hidden. One within the budget moves +/// and scales both. +#[test] +fn a_transform_past_the_line_budget_moves_the_art_with_its_shadow() { + let path = wavy(30_000); + assert!(Budget::EFFECTS.fits(Budget::of(&path)), "{:?}", Budget::of(&path)); + let candidate = Budget::of(&path.transformed(Affine::scale(1000.0))); + assert!(candidate.lines > MAX_EFFECT_LINES, "{candidate:?}"); + let b = path.bounds().unwrap(); + let n = path_node(30, path.clone(), vec![shadow(10.0, 5.0, 4.0), thousandfold()]); + let (g, carry) = apply_geometry_carrying(&n.appearance.effects, &path, b, &GeomContext::of(&n)); + assert_eq!(g, path, "the art stays where it is"); + assert!(carry.skipped()); + let mut drawn = n.appearance.effects.clone(); + carry.hide_skipped(&mut drawn); + assert!(close(drawn_shadow(&drawn), (10.0, 5.0, 4.0)), "drawn {:?}", drawn_shadow(&drawn)); + let out = baked(n.clone()); + assert_eq!(out.geometric_bounds(), Some(b)); + assert!(close(drawn_shadow(&out.appearance.effects), (10.0, 5.0, 4.0)), "baked {:?}", drawn_shadow(&out.appearance.effects)); + // A hidden shadow keeps its values too, once shown. + let mut hidden = shadow(10.0, 5.0, 4.0); + hidden.visible = false; + let out = baked(path_node(31, path.clone(), vec![hidden, thousandfold()])); + let mut shown = out.appearance.effects.clone(); + shown.iter_mut().for_each(|e| e.visible = true); + assert!(close(drawn_shadow(&shown), (10.0, 5.0, 4.0)), "shown {:?}", drawn_shadow(&shown)); + // A fill's own shadow before its own Transform effect stays too. + let mut fill = path_node(35, path.clone(), vec![]); + if let Some(item) = fill.appearance.items.first_mut() { + *item.effects_mut() = vec![shadow(10.0, 5.0, 4.0), thousandfold()]; + } + let out = baked(fill); + assert!(near(&shadows_in(&out), &[(10.0, 5.0)]), "baked fill {:?}", shadows_in(&out)); + // Within the budget, both move. + let doubled = path_node(32, path.clone(), vec![shadow(10.0, 5.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]); + let (g, carry) = apply_geometry_carrying(&doubled.appearance.effects, &path, b, &GeomContext::of(&doubled)); + assert!(g != path && !carry.skipped()); + let out = baked(doubled); + assert!(close(drawn_shadow(&out.appearance.effects), (20.0, 10.0, 8.0)), "baked {:?}", drawn_shadow(&out.appearance.effects)); +} + +/// The art of a path whose Transform effect is left out past the budget ([`crate::shape_art`], +/// as a group's or a mask's member is drawn) keeps the object's shadow and its fill's shadow with +/// their own offsets. +#[test] +fn the_shape_art_of_a_skipped_transform_keeps_its_shadows() { + let mut n = path_node(34, wavy(30_000), vec![shadow(10.0, 5.0, 4.0), thousandfold()]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = vec![shadow(1.0, 0.5, 0.0)]; + } + let art = crate::shape_art(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(10.0, 5.0), (1.0, 0.5)]), "{:?}", shadows_in(&art)); +} + +/// A path whose own Transform effect is left out past the budget, in a group whose geometry effect +/// (a move, which leaves offsets as they are) runs after its members' own, is drawn with its shadow +/// at its own offset; one within the budget scales the shadow with the path. +#[test] +fn a_members_skipped_transform_keeps_its_shadow_under_its_groups_effects() { + for (last, want) in [(thousandfold(), (10.0, 5.0)), (transform(json!({"scaleH": 200, "scaleV": 200})), (20.0, 10.0))] { + let member = path_node(38, wavy(30_000), vec![shadow(10.0, 5.0, 4.0), last.clone()]); + let g = group(37, vec![member], vec![transform(json!({"moveH": 5, "moveV": 5}))]); + let art = crate::evaluate_container(None, &g).unwrap(); + assert!(near(&shadows_in(&node_in(&art, 38)), &[want]), "{last:?}: {:?}", shadows_in(&node_in(&art, 38))); + } +} + +/// Of two Transform effects after a shadow, the shadow follows only the ones that ran: the first +/// left out past the budget, or hidden, and the second within it; and both when the first's +/// result is exactly at the limit. +#[test] +fn only_transforms_that_ran_move_a_preceding_shadow() { + let circle = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)); + let b = circle.bounds().unwrap(); + let doubled = Budget::of(&circle.transformed(Affine::scale(2.0))).lines; + assert!(doubled > Budget::of(&circle).lines); + let run = |effects: &[Effect], lines: usize| { + let mut carry = Carry::default(); + apply_geometry_carrying_within(effects, &circle, b, &GeomContext::default(), Budget { anchors: 1000, lines }, Some(&mut carry)); + let mut drawn = effects.to_vec(); + carry.hide_skipped(&mut drawn); + drawn_shadow(&drawn) + }; + let effects = vec![shadow(10.0, 0.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))]; + // Turned 90° counterclockwise on screen, (10, 0) goes to (0, -10). + let only_turned = run(&effects, doubled - 1); + assert!(only_turned.0.abs() < 1e-9 && (only_turned.1 + 10.0).abs() < 1e-9 && (only_turned.2 - 4.0).abs() < 1e-9, "{only_turned:?}"); + let both = run(&effects, doubled); + assert!(both.0.abs() < 1e-9 && (both.1 + 20.0).abs() < 1e-9 && (both.2 - 8.0).abs() < 1e-9, "{both:?}"); + let mut hidden = effects.clone(); + hidden[1].visible = false; + let turned = run(&hidden, doubled); + assert!(turned.0.abs() < 1e-9 && (turned.1 + 10.0).abs() < 1e-9 && (turned.2 - 4.0).abs() < 1e-9, "{turned:?}"); +} + +/// A fill's own Transform effect left out past the budget, after a Transform effect of that fill +/// that makes it a piece of its own (Transform Patterns), leaves the piece and the shadow before it +/// as they were. One within the budget scales both. +#[test] +fn a_skipped_item_transform_keeps_its_shadow() { + let path = wavy(30_000); + let b = path.bounds().unwrap(); + for (last, want) in [(thousandfold(), (10.0, 5.0)), (transform(json!({"scaleH": 200, "scaleV": 200})), (20.0, 10.0))] { + let mut n = path_node(33, path.clone(), vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = vec![transform(json!({"patterns": true})), shadow(10.0, 5.0, 4.0), last.clone()]; + } + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{last:?}: {:?}", shadows_in(&art)); + if want == (10.0, 5.0) { + assert_eq!(art.geometric_bounds(), Some(b), "the piece stays where it is"); + } + // The same with the Transform effect before the one that makes the piece. + let mut n = path_node(36, path.clone(), vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = vec![shadow(10.0, 5.0, 4.0), last.clone(), transform(json!({"patterns": true}))]; + } + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{last:?} first: {:?}", shadows_in(&art)); + } +} + +/// A group's Transform effect moves each member's shadow as it moves that member: the member whose +/// result would pass what the budget leaves keeps its shape and its shadow, and the other takes +/// both. +#[test] +fn a_group_transform_follows_only_the_members_it_moved() { + let small = path_node(41, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), vec![shadow(1.0, 0.5, 0.0)]); + let large = path_node(42, wavy(30_000), vec![shadow(1.0, 0.5, 0.0)]); + let g = group(40, vec![small, large], vec![thousandfold()]); + let art = crate::evaluate_container(None, &g).unwrap(); + assert!(near(&shadows_in(&node_in(&art, 41)), &[(1000.0, 500.0)]), "{:?}", shadows_in(&node_in(&art, 41))); + assert!(near(&shadows_in(&node_in(&art, 42)), &[(1.0, 0.5)]), "{:?}", shadows_in(&node_in(&art, 42))); +} + +/// A Transform effect that makes copies, or has Transform Objects off, keeps its own rule for the +/// shadow before it, past the budget or not: the shadow stays as it is, and the pass does not +/// report it as left out. +#[test] +fn copying_and_object_disabled_transforms_keep_their_existing_shadow_rules() { + let path = wavy(30_000); + let b = path.bounds().unwrap(); + for t in [ + transform(json!({"scaleH": 100000, "scaleV": 100000, "copies": 1})), + transform(json!({"scaleH": 100000, "scaleV": 100000, "objects": false})), + ] { + let effects = vec![shadow(10.0, 5.0, 4.0), t.clone()]; + let (_, carry) = apply_geometry_carrying(&effects, &path, b, &GeomContext::default()); + assert!(!carry.skipped(), "{t:?}"); + assert!(close(drawn_shadow(&effects), (10.0, 5.0, 4.0)), "{t:?}: {:?}", drawn_shadow(&effects)); + } +} diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 4fd1f1ce96..52b20cdddc 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -369,11 +369,7 @@ fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result Result = const { std::cell::Cell::new(0) }; +} + +/// The scope's objects after a pass replaced `found`: each found object gives way to the ids of +/// what replaces it, in its place, and the others keep their order. An id that `roots` lists more +/// than once is replaced at its first places, once for each time `found` lists it. One walk of +/// `roots`. +pub(super) fn replace_roots(roots: &[NodeId], found: &[(NodeId, Vec)]) -> Vec { + let mut with: HashMap = HashMap::with_capacity(found.len()); + for (id, nodes) in found { + with.entry(*id).or_insert((nodes.as_slice(), 0)).1 += 1; + } + let mut out = Vec::with_capacity(roots.len()); + for r in roots { + #[cfg(test)] + ROOTS_SEEN.set(ROOTS_SEEN.get() + 1); + match with.get_mut(r) { + Some((nodes, left)) if *left > 0 => { + *left -= 1; + out.extend(nodes.iter().map(|n| n.id)); + } + _ => out.push(*r), + } + } + out +} + /// Run `f` on a copy of each path in scope (not guides), and keep the copies it changed: `f` /// returns how many issues it fixed. fn reshape(d: &mut Document, scope: &Scope, f: impl Fn(&Node, &mut PathData) -> usize) -> Result { @@ -479,13 +505,38 @@ fn near_points(d: &mut Document, scope: &Scope) -> Result { fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result { let Scope::Document = scope else { return Ok(Outcome::default()) }; let empty = empty_artboards(d); - // The last first, so the others keep their places. - for &i in empty.iter().rev() { - if i < d.artboards.len() { - let id = d.artboards.remove(i).id; - d.retire_artboard_id(id); - d.retain_guides(sel, |g| g.artboard != Some(id)); - } + if empty.is_empty() { + return Ok(Outcome::default()); } + // `empty` is in artboard order: one walk drops them and the others keep their order. + let mut gone: HashSet = HashSet::with_capacity(empty.len()); + let mut i = 0; + d.artboards.retain(|a| { + let keep = empty.binary_search(&i).is_err(); + i += 1; + if !keep { + gone.insert(a.id); + } + keep + }); + count_artboard_pass(); + for &id in &gone { + d.retire_artboard_id(id); + } + d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a))); + count_artboard_pass(); Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty }) } + +#[cfg(test)] +thread_local! { + /// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this + /// thread. + pub(super) static ARTBOARD_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Tests: count one walk over the artboards or the ruler guides. +fn count_artboard_pass() { + #[cfg(test)] + ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1); +} diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 1184687366..2ccdce9dad 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -389,6 +389,150 @@ fn the_scope_is_the_selection_or_the_document() { } } +/// The ids of the first layer's objects, bottom to top. +fn layer_ids(s: &Session) -> Vec { + match &s.doc().unwrap().doc.layers[0].kind { + NodeKind::Layer { children, .. } => children.iter().map(|c| c.id).collect(), + other => panic!("not a layer: {other:?}"), + } +} + +/// A plain group of the one object `id`. +fn group(s: &mut Session, id: NodeId) -> NodeId { + wrap(s, id, |children| NodeKind::Group { children, clip: false }) +} + +#[test] +fn unwrapping_selected_groups_keeps_their_places_and_order() { + let mut s = session(); + let c1 = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let a = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let g1 = group(&mut s, a); + let c2 = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let empty = s + .edit("t", |d, _| { + let (parent, i, _) = d.position(c2).unwrap(); + let g = d.alloc_id(); + Ok(d.insert(parent, i + 1, Node::new(g, NodeKind::Group { children: vec![], clip: false }))?) + }) + .unwrap(); + let c3 = rect(&mut s, 100.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 130.0, 10.0, 20.0, 20.0); + let inner = group(&mut s, b); + let outer = group(&mut s, inner); + let c4 = rect(&mut s, 160.0, 10.0, 20.0, 20.0); + let x = rect(&mut s, 190.0, 10.0, 20.0, 20.0); + let unselected = group(&mut s, x); + assert_eq!(layer_ids(&s), [c1, g1, c2, empty, c3, outer, c4, unselected]); + select(&mut s, &[c1, g1, c2, empty, c3, outer, c4]); + let check = s.execute("addon.cleanup.check", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap(); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap(); + // The inner group is found only once its outer group is gone: on a later pass, as a root. + assert_eq!(r["fixed"]["needlessGroups"], 4, "{r}"); + assert_eq!(check["total"], 4, "the check finds what Fix fixes: {check}"); + let mut listed: Vec = check["findings"][0]["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + listed.sort_unstable(); + let mut gone: Vec = [g1, empty, outer, inner].iter().map(|i| i.0).collect(); + gone.sort_unstable(); + assert_eq!(listed, gone); + assert_eq!(layer_ids(&s), [c1, a, c2, c3, b, c4, unselected], "each member takes its group's place"); + for id in [g1, empty, outer, inner] { + assert!(node(&s, id).is_none(), "{id:?} unwrapped"); + } +} + +#[test] +fn fix_all_reaches_the_members_of_unwrapped_selected_groups() { + let mut s = session(); + rect(&mut s, 200.0, 20.0, 80.0, 60.0); + let below = rect(&mut s, 20.0, 150.0, 30.0, 30.0); + let copy = s + .edit("t", |d, _| { + let mut copy = d.node(below).unwrap().clone(); + copy.id = d.alloc_id(); + let (parent, i, _) = d.position(below).unwrap(); + Ok(d.insert(parent, i + 1, copy)?) + }) + .unwrap(); + let g = group(&mut s, copy); + select(&mut s, &[below, g]); + // The copy sits apart from the object it doubles until its group is unwrapped. + assert_eq!(found(&mut s, "duplicateObjects", "selection"), 0); + assert_eq!(found(&mut s, "needlessGroups", "selection"), 1); + let r = s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap(); + assert_eq!((r["fixed"]["needlessGroups"].as_u64(), r["fixed"]["duplicateObjects"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(node(&s, g).is_none() && node(&s, copy).is_none()); + assert!(node(&s, below).is_some()); +} + +#[test] +fn replacing_roots_takes_each_found_object_at_its_first_place() { + use super::checks::replace_roots; + let n = |id: u64| Node::new(NodeId(id), NodeKind::Group { children: vec![], clip: false }); + let ids = |v: &[u64]| v.iter().map(|i| NodeId(*i)).collect::>(); + let roots = ids(&[1, 2, 1, 3]); + assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![n(10), n(11)])]), ids(&[10, 11, 2, 1, 3])); + assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![]), (NodeId(1), vec![])]), ids(&[2, 3]), "listed twice: both places"); + assert_eq!(replace_roots(&roots, &[(NodeId(3), vec![]), (NodeId(2), vec![n(20)])]), ids(&[1, 20, 1])); + // A found object inside a root leaves the roots as they are. + assert_eq!(replace_roots(&roots, &[(NodeId(7), vec![n(70)])]), roots); + assert_eq!(replace_roots(&[], &[(NodeId(7), vec![])]), ids(&[])); +} + +/// How many scope roots fixing `n` selected stray points above `n` selected clean objects looks at. +fn roots_seen_removing(n: usize) -> usize { + use super::checks::ROOTS_SEEN; + let mut s = session(); + let mut ids: Vec = (0..n).map(|i| rect(&mut s, (i % 20) as f64 * 15.0, (i / 20) as f64 * 15.0, 10.0, 10.0)).collect(); + ids.extend((0..n).map(|i| path(&mut s, json!([{"x": i % 300, "y": i / 300}]), false))); + select(&mut s, &ids); + ROOTS_SEEN.set(0); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"], "scope": "selection"})).unwrap(); + assert_eq!(r["total"].as_u64(), Some(n as u64)); + assert_eq!(layer_ids(&s), ids[..n]); + ROOTS_SEEN.get() +} + +#[test] +fn removing_many_selected_objects_looks_at_each_root_once_a_pass() { + let (small, large) = (roots_seen_removing(150), roots_seen_removing(300)); + assert_eq!(small, 300, "one pass over the 300 roots"); + assert!(large <= 2 * small, "{small} roots looked at for 150 removals, {large} for 300"); +} + +#[test] +fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() { + use super::checks::ARTBOARD_PASSES; + let mut s = session(); + rect(&mut s, 200.0, 20.0, 80.0, 60.0); + let (keep, gone) = s + .edit("t", |d, _| { + let first = d.artboards[0].id; + let keep = d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(first), ..vectorcraft_doc::Guide::new(true, 100.0) }); + let mut gone = vec![]; + for i in 0..40 { + let mut a = d.artboards[0].clone(); + a.id = d.next_artboard_id(); + let x = 1000.0 + i as f64 * 200.0; + a.rect = Rect::new(x, 1000.0, x + 100.0, 1100.0); + gone.push(d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(a.id), ..vectorcraft_doc::Guide::new(true, x + 50.0) })); + d.artboards.push(a); + } + Ok((keep, gone)) + }) + .unwrap(); + let first = s.doc().unwrap().doc.artboards[0].clone(); + ARTBOARD_PASSES.set(0); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["emptyArtboards"], "scope": "document"})).unwrap(); + assert_eq!(r["total"].as_u64(), Some(40), "{r}"); + assert_eq!(ARTBOARD_PASSES.get(), 2, "one walk over the artboards and one over the guides"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards, vec![first]); + let left: Vec> = d.ruler_guides().into_iter().map(|g| g.id).collect(); + assert_eq!(left, vec![Some(keep)]); + assert!(gone.iter().all(|id| !left.contains(&Some(*id)))); +} + #[test] fn show_selects_the_objects_with_the_issue() { let mut s = session(); diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index cd30d29d15..ced10c9912 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", has_selection, filter ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Print Ink Fixes", ["Object", "Plug-ins", "Color Effects"], None, - "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", has_doc, print_fixes::run ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs index a0dd332fec..c5f430eb76 100644 --- a/crates/engine/src/cmd/addons/color/print_fixes.rs +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -11,7 +11,7 @@ use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_doc::overprint::{OverprintBlack, is_white}; use vectorcraft_doc::{ColorMode, Node, NodeKind}; -use super::super::super::colorcmds::{Recolor, Scope}; +use super::super::super::colorcmds::{Recolor, Scope, keeping_series_paint}; use super::super::super::overprint::edit_counted; use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param}; use crate::{Result, Session}; @@ -149,7 +149,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { true }; let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false }; - ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids); + ink = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids)))?; } if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 { return Ok(ink); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 79a203c421..5a129f6d32 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -508,8 +508,9 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke /// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). -/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and -/// its undo history stay as they are. +/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on +/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and +/// when `f` leaves every target as it was, the document and its undo history stay as they are. pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], @@ -519,12 +520,13 @@ pub(crate) fn edit_items( fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, ) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false)) + edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) } -/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns -/// whether it changed the document; when it did not and `f` left every target as it was, the -/// document and its undo history stay as they are. +/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and +/// the ids `f` ran on, inside the same undo step. `after` returns whether it changed the document; +/// when it did not and `f` left every target as it was, the document and its undo history stay as +/// they are. #[allow(clippy::too_many_arguments)] pub(crate) fn edit_items_then( s: &mut Session, @@ -534,7 +536,7 @@ pub(crate) fn edit_items_then( label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, - mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result, + mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, ) -> Result<()> { if ids.is_empty() { return Ok(()); @@ -550,7 +552,7 @@ pub(crate) fn edit_items_then( f(n, index)?; edited.push(*id); } - let more = after(d, &edited)?; + let more = after(&before, d, &edited)?; Ok(count_changed(d, &before, &edited) + usize::from(more)) })?; Ok(()) @@ -791,6 +793,19 @@ pub(super) fn capture_series_writes( kept || painted || stored } +/// Store on the graph what an edit of `ids` (from `before` to `d`) changed on the series marks among them: the paint, +/// weight, opacity or blend mode of the topmost visible fill and stroke, and the object opacity and blend mode. Rows +/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series, +/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed. +pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + // Only objects directly in a series group can be marks: other edits skip the search of the document for graphs. + let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); + let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); + // A later mark's capture replaces an earlier one's: take them in paint order. + marks.sort_by_cached_key(|id| d.index_path(*id)); + !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs index 0aeda59731..340cf98459 100644 --- a/crates/engine/src/cmd/arrowheads.rs +++ b/crates/engine/src/cmd/arrowheads.rs @@ -18,7 +18,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Nod use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath}; use vectorcraft_pathops::BoolOp; -use super::{CommandSpec, Result, Session, always, bad, cmd}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd}; /// The arrowheads file's name, in the folder of the preferences. pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Arrowheads", [], None, - "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus", + "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web, or the file is outside the automation read roots), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read (naming the file by its file name outside the read roots), or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus", always, list ), @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Edit Arrowheads", [], None, - "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}", + "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads. Under automation read roots, a file outside them is an error, and nothing is made or opened → {path, created: whether the file was made now}", has_file, edit ), @@ -55,7 +55,7 @@ pub fn specs() -> Vec { "Reload Arrowheads", [], None, - "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}", + "{} read the arrowheads file again (a save of it from VectorCraft does this already; under automation read roots, a file outside them is an error and is not read) → {count, skipped: [{name, reason}]}", has_file, reload ), @@ -409,13 +409,36 @@ fn skipped(s: &Session) -> Vec { s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect() } +/// The arrowheads file at `path` checked for reading against the automation roots in force: an agent +/// confined to read roots reaches the file only inside them. The error names the file by its file +/// name outside them. +fn may_reach(path: &str) -> Result<()> { + crate::file_access::check_read(path).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), path)) +} + +/// `msg`, an error about the arrowheads file at `path`, as the request being served may see it: the +/// file named whole inside the read roots, else by its file name. +fn shown_error(msg: &str, path: Option<&str>) -> String { + let Some(path) = path else { return msg.to_string() }; + match super::fileio::naming_in_error(EngineError::Other(msg.to_string()), path) { + EngineError::Other(m) => m, + e => e.to_string(), + } +} + fn list(s: &mut Session, _: &Value) -> Result { let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect(); - Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error})) + let path = s.arrowheads.path.as_deref(); + let file = path.filter(|p| crate::file_access::check_read(p).is_ok()); + let error = s.arrowheads.error.as_deref().map(|e| shown_error(e, path)); + Ok(json!({"arrowheads": heads, "file": file, "skipped": skipped(s), "error": error})) } fn reload(s: &mut Session, _: &Value) -> Result { const C: &str = "stroke.arrowheads.reload"; + if let Some(path) = &s.arrowheads.path { + may_reach(path)?; + } s.reload_arrowheads(); if let Some(e) = &s.arrowheads.error { return Err(bad(C, e.clone())); @@ -426,8 +449,11 @@ fn reload(s: &mut Session, _: &Value) -> Result { fn edit(s: &mut Session, _: &Value) -> Result { const C: &str = "stroke.arrowheads.edit"; let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; + // Checked before anything is made, brought to the front or opened. + may_reach(&path)?; if let Some(dir) = std::path::Path::new(&path).parent() { - std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + let dir = dir.to_string_lossy(); + std::fs::create_dir_all(&*dir).map_err(|e| super::fileio::naming_in_error(bad(C, format!("{dir}: {e}")), &dir))?; } // Made only when no file is there, also when another run makes one at the same moment. let bytes = vectorcraft_format::save_file(&starter()); @@ -446,7 +472,8 @@ fn edit(s: &mut Session, _: &Value) -> Result { s.set_active(i); } None => { - crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?; + // Opened in the request's own scope, so the document keeps the roots it was opened under. + s.execute("document.open", &json!({ "path": path }))?; } } Ok(json!({"path": path, "created": created})) diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbc56dbc15..6de2fbf9a2 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -3,7 +3,11 @@ //! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text //! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are //! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead. +//! +//! Convert to Indexed Color uses the same visitor for embedded images only, and stores each one as +//! a palette PNG. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -15,7 +19,9 @@ use vectorcraft_doc::live::lerp_color; use vectorcraft_doc::pattern::PatternDef; pub(crate) use vectorcraft_doc::recolor::map_paint; use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; -use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageBlob, MAX_RASTER_ALLOC, Node, NodeId, NodeKind}; +use vectorcraft_render::encode::png::{PngOptions, encode_indexed}; +use vectorcraft_render::encode::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to}; use super::edit::selected_roots; use super::{ @@ -30,7 +36,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -66,6 +72,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "edit.colors.toIndexed", + "Convert to Indexed Color…", + ["Edit", "Edit Colors"], + None, + "{reduction?: \"selective\" (default)|\"perceptual\"|\"adaptive\"|\"web\"|\"gray\"|\"blackWhite\" (chosen as for a PNG-8 export)|\"uniform\" (evenly spaced levels of red, green and blue: the most levels, 2 to 6, whose cube fits in `colors` with one entry left for transparency)|\"document\" (the colors of the document's swatches, at most 255), colors?: 2..256 (default 256; the most palette entries, the transparent one included; at least 9 for uniform; refused for blackWhite and document), dither?: \"diffusion\" (default)|\"pattern\"|\"noise\"|\"none\", ditherAmount?: 0..100 (default 100), ids?} store each embedded image in the selection (in groups too) as an indexed-color PNG: a palette of at most 256 RGB colors, each pixel the index of one; pixels under half opacity become one transparent entry and the others opaque, keeping their color; linked images, colorized images and the images of color glyphs in outlined type keep their pixels; each image changed becomes a new image, one undo step for all; an image that is an indexed PNG already and would show the same pixels is left as it is, and no undo step is recorded when no image changes; an image of more than 134,217,728 pixels (33,554,432 in the web app) is refused with an error and nothing changes; reduction and dither ids are read as for a PNG-8 export, ignoring case → {changed: images changed, notChanged: the locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet, warning?} (as edit.colors.invert)", + has_selection, + to_indexed + ), cmd!( "edit.colors.saturate", "Saturate…", @@ -89,7 +104,7 @@ pub fn specs() -> Vec { "Blend Front to Back", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}", + "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}", has_selection, |s, _| blend(s, BlendOrder::Stack) ), @@ -149,6 +164,13 @@ fn gray_level(c: Color) -> f32 { /// gradient stop): `(colour, link, tint)`, true when it changed them. pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; +/// What a pass that remakes whole images makes of one: its new copy, `None` when it stays as it is. +type Remake<'a> = dyn Fn(&ImageBlob) -> Option + 'a; + +/// The objects a pass recolors, and the locked and hidden objects the selection left out, each +/// with whether it is hidden ([`pass_targets`]). +type Targets = (Vec, Vec<(NodeId, bool)>); + /// The identity colour map (a pass that only maps linked colours). fn same(c: Color) -> Color { c @@ -191,6 +213,8 @@ pub(crate) struct Recolor<'a> { scope: Scope, /// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]). gray_images: bool, + /// Makes each embedded image's new copy in place of `f` (`None`: it stays as it is). + remake: Option<&'a Remake<'a>>, /// Image key → the key of its recoloured copy (`None`: unchanged). images: HashMap>, /// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured). @@ -207,6 +231,7 @@ impl<'a> Recolor<'a> { links: None, scope, gray_images: false, + remake: None, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], @@ -229,7 +254,7 @@ impl<'a> Recolor<'a> { /// A pass that maps colors as this one does, with nothing recolored yet. fn again(&self) -> Self { - Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) } + Self { links: self.links, gray_images: self.gray_images, remake: self.remake, ..Self::new(self.scope, self.f) } } /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other @@ -328,7 +353,9 @@ impl<'a> Recolor<'a> { } let f = self.f; let blob = d.images.get(key)?; - let new = if self.gray_images { + let new = if let Some(remake) = self.remake { + remake(blob) + } else if self.gray_images { blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0]) } else { blob.map_rgb(|[r, g, b]| { @@ -446,8 +473,11 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let (n, not_changed) = s.edit(label, |d, _| { let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); - let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + let n = keeping_series_paint(d, &ids, |d| { + let n = pass.run(d, &ids); + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + Ok(n) + })?; Ok((n, not_changed)) })?; let mut out = json!({ "changed": n }); @@ -455,6 +485,19 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - Ok(out) } +/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed +/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn +/// again. +pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result) -> Result { + let marks = super::graph::series_marks_in(d, roots); + let before = (!marks.is_empty()).then(|| d.clone()); + let n = recolor(d)?; + if let Some(before) = before.filter(|_| n > 0) { + super::appearance::capture_series_changes(&before, d, &marks); + } + Ok(n) +} + /// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with /// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All /// takes (in isolation mode, those of the isolated group), these are the objects beside them that @@ -534,6 +577,187 @@ impl NotChanged { } } +/// The objects a pass recolors (the `ids` param's, default: the selection's) and the locked and +/// hidden objects the selection left out ([`left_out`]), each with whether it is hidden. +fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result { + Ok(match ids_param(p, "ids", cmd)? { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } + }) +} + +/// Edit › Edit Colors › Convert to Indexed Color: the embedded images of the objects stored as +/// indexed-color PNGs ([`Indexing`]). The pass runs on a copy of the document, so no undo step is +/// recorded when no image changes. +fn to_indexed(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let indexing = Indexing::of(s, p)?; + // A typing session ends here as `s.edit` would end it, before the copy is taken, so that the + // copy holds what was typed. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let remake = |b: &ImageBlob| indexing.convert(b); + let images = Scope { fill: false, stroke: false, images: true, patterns: false }; + let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) }; + // A left-out image counts as one the pass would change when it is an image that is not an + // indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key. + let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![])); + let (ids, left) = pass_targets(s, p, C)?; + let st = s.doc()?; + let colorized_ok = super::paint::check_colorized(&st.doc).is_ok(); + let not_changed = NotChanged::of( + Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden), + ); + let mut doc = (*st.doc).clone(); + let n = pass.run(&mut doc, &ids); + if let Some((w, h)) = indexing.too_large.get() { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS))); + } + if n > 0 { + s.edit("Convert to Indexed Color", |d, _| { + *d = doc; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) + })?; + } + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// How Convert to Indexed Color reduces an image's colors: the palette options of a PNG-8 +/// export, with transparency kept and no matte, and the fixed palette of the uniform and document +/// reductions. +struct Indexing { + options: PaletteOptions, + fixed: Option>, + /// The size of an image [`Self::convert`] left as it is because it has more than + /// [`Self::MAX_PIXELS`] pixels. + too_large: Cell>, +} + +impl Indexing { + /// The most pixels an image may have. Converting one holds at most about 8 bytes a pixel at + /// once: decoding (the decoded samples and the RGBA), quantizing (the RGBA, the colors and the + /// indices) and writing the PNG after the RGBA is freed (the indices, the packed and filtered + /// rows and the compressed data). 16 bytes a pixel keeps that within [`MAX_RASTER_ALLOC`] twice + /// over. + const MAX_PIXELS: u64 = MAX_RASTER_ALLOC / 16; + + /// The reduction `p` asks for (see `edit.colors.toIndexed`); a value out of range or of the + /// wrong type is an error. Reduction and dither ids are read as a PNG-8 export reads them. + fn of(s: &Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let reduction = text_param(p, "reduction", C)?.unwrap_or("selective"); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => { + Some(v.as_u64().filter(|n| (2..=256).contains(n)).ok_or_else(|| bad(C, "`colors` must be a whole number from 2 to 256"))? as u16) + } + }; + let dither = match text_param(p, "dither", C)? { + None => Dither::Diffusion, + Some(d) => Dither::from_id(d).ok_or_else(|| bad(C, format!("unknown dither `{d}` (diffusion, pattern, noise or none)")))?, + }; + let dither_amount = match p.get("ditherAmount") { + None | Some(Value::Null) => 100, + Some(v) => { + v.as_f64().filter(|x| (0.0..=100.0).contains(x)).ok_or_else(|| bad(C, "`ditherAmount` must be a number from 0 to 100"))?.round() as u8 + } + }; + let options = + |r: Reduction| PaletteOptions { colors: colors.unwrap_or(256), reduction: r, dither, dither_amount, transparency: true, matte: None }; + let refuse_colors = |name: &str| match colors { + Some(_) => Err(bad(C, format!("`colors` does not apply to the {name} reduction"))), + None => Ok(()), + }; + let fixed = |palette: Vec<[u8; 3]>| Self { options: options(Reduction::Adaptive), fixed: Some(palette), too_large: Cell::new(None) }; + Ok(match reduction.to_ascii_lowercase().as_str() { + "uniform" => { + let n = colors.unwrap_or(256); + // One entry is kept for transparent pixels. + let levels = + (2..=6u16).rev().find(|l| l.pow(3) < n).ok_or_else(|| bad(C, "`colors` must be at least 9 for the uniform reduction"))?; + let level = |i: u16| (u32::from(i) * 255 / u32::from(levels - 1)) as u8; + let mut palette = Vec::with_capacity(usize::from(levels.pow(3))); + for r in 0..levels { + for g in 0..levels { + for b in 0..levels { + palette.push([level(r), level(g), level(b)]); + } + } + } + fixed(palette) + } + "document" => { + refuse_colors("document")?; + let mut palette: Vec<[u8; 3]> = vec![]; + for c in s.doc()?.doc.swatches_iter().filter_map(|sw| sw.paint.color()) { + let [r, g, b, _] = c.to_rgba8(1.0); + if !palette.contains(&[r, g, b]) { + palette.push([r, g, b]); + } + } + if palette.is_empty() { + return Err(bad(C, "the document has no color swatches")); + } + if palette.len() > 255 { + return Err(bad(C, format!("the document's swatches hold {} colors; the document reduction takes at most 255", palette.len()))); + } + fixed(palette) + } + id => { + let r = Reduction::from_id(id).ok_or_else(|| { + bad(C, format!("unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document)")) + })?; + if r == Reduction::BlackWhite { + refuse_colors("blackWhite")?; + } + Self { options: options(r), fixed: None, too_large: Cell::new(None) } + } + }) + } + + /// Image `b` as an indexed-color PNG; `None` when it can't be decoded, when it has more than + /// [`Self::MAX_PIXELS`] pixels (noted in `too_large`), or when it is an indexed PNG already and + /// every pixel would show as it does. + fn convert(&self, b: &ImageBlob) -> Option { + let (w, h) = b.pixel_size()?; + if u64::from(w) * u64::from(h) > Self::MAX_PIXELS { + self.too_large.set(Some((w, h))); + return None; + } + let (w, h, rgba) = b.rgba()?; + let ix = match &self.fixed { + Some(palette) => quantize_to(&rgba, w, h, &self.options, palette), + None => quantize(&rgba, w, h, &self.options), + }; + if b.indexed_png() && shows_same(&ix, &rgba) { + return None; + } + // The PNG writer's buffers take the place of the pixels. + drop(rgba); + Some(ImageBlob::png(encode_indexed(&ix, &PngOptions::default()).ok()?)) + } +} + +/// Whether palette image `ix` shows every pixel of straight RGBA `rgba` as it is: transparent +/// where they are transparent, and the same color where they are opaque. +fn shows_same(ix: &Indexed, rgba: &[u8]) -> bool { + let px = rgba.as_chunks::<4>().0; + px.len() == ix.indices.len() + && px.iter().zip(&ix.indices).all(|(p, &i)| match (p[3], ix.transparent == Some(i)) { + (0, clear) => clear, + (255, false) => ix.palette.get(usize::from(i)).is_some_and(|c| c[..] == p[..3]), + _ => false, + }) +} + fn saturate(s: &mut Session, p: &Value) -> Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { @@ -679,7 +903,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { }; let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); - let changed = s.edit(label, |d, _| { + let run = |d: &mut Document| -> Result { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model); @@ -698,7 +922,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { } super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) - })?; + }; + let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?; Ok(json!({ "changed": changed })) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 6c75eae128..c94de5d3b2 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Gamut Warning", [], None, - "{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}", + "{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colors that can't be printed in the document's assigned CMYK profile, else the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}", has_doc, gamut_check ), diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index a43325becd..f1bd5ceb2c 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", has_doc, info ), @@ -425,6 +425,64 @@ fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Ve if follows { used } else { vec![] } } +/// Whether `n` draws, as far as its own state goes: a hidden object, and an object at an opacity +/// of 0, paint no color, and neither do their members. +fn draws(n: &Node) -> bool { + n.visible && n.opacity > 0.0 +} + +/// The object of `d` at index path `path`, with whether the groups and layers around it draw +/// ([`draws`]). +fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool)> { + let (first, rest) = path.split_first()?; + let mut at: &Node = d.layers.get(*first)?; + let mut drawn = true; + for &i in rest { + drawn &= draws(at); + at = &**at.children()?.get(i)?; + } + Some((at, drawn)) +} + +/// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n` +/// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of +/// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with. +fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 3] { + let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label)); + let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient]; + let painted_runs = painted_runs(d, spots, n); + let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label)); + // The characters' own paints: an inline graphic draws no outline and does not take its run's + // paint (a list marker set in its run does), and a stroke with no width paints nothing. + if let Some((t, painted)) = &painted_runs { + for run in painted.iter().filter_map(|i| t.runs.get(*i)) { + let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); + for paint in std::iter::once(&run.style.fill).chain(stroke) { + for (used, label) in used.iter_mut().zip(labels) { + for name in label(paint) { + if !used.contains(&name) { + used.push(name); + } + } + } + } + } + } + // A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take the + // color, as the renderer draws it. + if let NodeKind::Image(im) = &n.kind + && let Some(colorize) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| b.takes_colorize()) + { + for name in spot(colorize) { + if !used[0].contains(&name) { + used[0].insert(0, name); + } + } + } + used +} + fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "document.info"; let category = str_param(p, "category"); @@ -440,8 +498,13 @@ fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let d = &st.doc; let selection_only = bool_or(p, "selectionOnly", false); - let roots: Vec<&Node> = - if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() }; + // Each root with whether the groups and layers around it draw: a selected object inside a hidden group or layer, or one + // at an opacity of 0, paints nothing. + let roots: Vec<(&Node, bool)> = if selection_only { + st.selection.objects.iter().filter_map(|id| node_and_drawn(d, &d.index_path(*id)?)).collect() + } else { + d.layers.iter().map(|l| (&**l, true)).collect() + }; let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); // The paths' total length, as Document Info › Objects shows it (the length of a cut line, a // strip of LEDs along the letters, #1958). @@ -467,7 +530,6 @@ fn info(s: &mut Session, p: &Value) -> Result { let mut spot_objects: Vec = vec![]; let mut spot_rows: Vec<(String, String)> = vec![]; let mut spot = BTreeSet::new(); - let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label)); let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; // `library` holds the Brushes panel's brushes, and `brushes` the brushes the visited objects' strokes draw with, each once, // in walk order. @@ -494,10 +556,18 @@ fn info(s: &mut Session, p: &Value) -> Result { members.extend(children.iter().map(|c| std::ptr::from_ref(&**c))); } }); - for r in roots { - r.walk(&mut |n| { + // Depth first, in paint order, as `Node::walk` visits: each object with whether it and the groups and layers around it + // draw. An object that does not draw is still counted, and its members are visited. + let mut stack: Vec<(&Node, bool)> = vec![]; + for (r, drawn) in roots { + stack.push((r, drawn)); + while let Some((n, around)) = stack.pop() { if !seen.insert(std::ptr::from_ref(n)) { - return; + continue; + } + let drawn = around && draws(n); + if let Some(ch) = n.children() { + stack.extend(ch.iter().rev().map(|c| (&**c, drawn))); } let k = match &n.kind { // The Guides row counts paths made into guides; ruler guides are not objects. @@ -545,42 +615,8 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - // The spot colors, the patterns and the gradients this object's fills and strokes paint, each once. - let painted_runs = painted_runs(d, &spot_swatches, n); - let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label); - let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern); - let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient); - // The characters' own paints: an inline graphic draws no outline and does not take its - // run's paint (a list marker set in its run does), and a stroke with no width paints - // nothing. - if let Some((t, painted)) = &painted_runs { - for run in painted.iter().filter_map(|i| t.runs.get(*i)) { - let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); - for paint in std::iter::once(&run.style.fill).chain(stroke) { - let labels: [(&mut Vec, &PaintNames<'_>); 3] = - [(&mut used_spots, &spot_label), (&mut used_patterns, &pattern), (&mut used_gradients, &gradient)]; - for (used, label) in labels { - for name in label(paint) { - if !used.contains(&name) { - used.push(name); - } - } - } - } - } - } - // A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take - // the color, as the renderer draws it. - if let NodeKind::Image(im) = &n.kind - && let Some(colorize) = n.extras().colorize.as_deref() - && d.image_blob(im).is_some_and(|b| b.takes_colorize()) - { - for name in spot_label(colorize) { - if !used_spots.contains(&name) { - used_spots.insert(0, name); - } - } - } + // The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw. + let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. if !used_spots.is_empty() { let name = n.display_name(); @@ -615,9 +651,11 @@ fn info(s: &mut Session, p: &Value) -> Result { { styles.push(&g.name); } - for b in applied_brushes(d, n, &members) { - if !brushes.contains(&b) { - brushes.push(b); + if drawn { + for b in applied_brushes(d, n, &members) { + if !brushes.contains(&b) { + brushes.push(b); + } } } if let NodeKind::Repeat(spec) = &n.kind { @@ -625,7 +663,7 @@ fn info(s: &mut Session, p: &Value) -> Result { repeats.push(entry); repeat_rows.push(row); } - }); + } } // The document's fonts include those of the type in opacity masks and in the art of its // symbols and patterns, as Find Font lists them. @@ -633,7 +671,7 @@ fn info(s: &mut Session, p: &Value) -> Result { fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); - // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two + // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); @@ -910,7 +948,7 @@ mod tests { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None; assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([])); - // Two panel brushes with one name: the type is the one the renderer draws with, the last one's. + // Two panel brushes with one name: the type is the one the renderer draws with, the first one's. let twin = square(&mut s, 140.0); s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap(); let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); @@ -920,7 +958,7 @@ mod tests { lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind })); vectorcraft_brush::store(d, &lib); d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into()); - assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}])); + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "art"}])); // Type: a brushed stroke on drawn characters is listed, one on spaces alone is not. let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap(); let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); @@ -1455,6 +1493,115 @@ mod tests { assert_eq!(spots(&mut s), (vec![], None, None)); } + /// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object, + /// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of + /// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no + /// color. Objects still counts them. + #[test] + fn info_skips_paints_on_hidden_and_clear_objects_and_their_members() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0, "m": 1, "y": 1, "k": 0}, "spot": true})).unwrap(); + let square = |s: &mut Session, x: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap() + }; + let tile = square(&mut s, 0.0); + s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap(); + // A patterned square that stays drawn, an object at an opacity of 0 and a hidden one. + let (shown, clear, hidden) = (square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0)); + s.execute("paint.setFill", &json!({"ids": [shown, clear], "swatch": "Dots"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [hidden], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [shown, clear, hidden], "none": true})).unwrap(); + // A brushed square in a group. + let (brushed, other) = (square(&mut s, 80.0), square(&mut s, 100.0)); + s.execute("brush.apply", &json!({"ids": [brushed], "name": "Charcoal"})).unwrap(); + s.execute("select.set", &json!({"ids": [brushed, other]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + // Type whose characters are filled with the spot color. + let word = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [word], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [word], "none": true})).unwrap(); + // A grayscale image colorized with the spot color. + let mut bytes = Vec::new(); + image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + let image = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(bytes)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + // A square with a gradient on a layer of its own. + let layer = s.execute("layer.new", &json!({"name": "Back"})).unwrap()["id"].as_u64().unwrap(); + let ramp = square(&mut s, 120.0); + s.execute( + "paint.setFill", + &json!({"ids": [ramp], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}), + ) + .unwrap(); + s.execute("paint.setStroke", &json!({"ids": [ramp], "none": true})).unwrap(); + let listed = |s: &mut Session, p: Value| { + let i = s.execute("document.info", &p).unwrap(); + let ids = |k: &str| -> Vec { + let mut ids: Vec = i[k].as_array().unwrap().iter().map(|o| o["id"].as_u64().unwrap()).collect(); + ids.sort_unstable(); + ids + }; + (i["brushNames"].clone(), ids("spotObjects"), ids("patternObjects"), ids("gradientObjects"), i["objects"].clone()) + }; + // Everything drawn: each object is listed. + let (brushes, spots, patterns, gradients, before) = listed(&mut s, json!({})); + assert_eq!(brushes, json!(["Charcoal"])); + // New squares take the new spot color as their fill, the group's members included. + assert_eq!((spots, patterns, gradients), (vec![hidden, brushed, other, word, image.0], vec![shown, clear], vec![ramp])); + s.edit("hide", |d, _| { + d.node_mut(NodeId(clear)).unwrap().opacity = 0.0; + d.node_mut(NodeId(hidden)).unwrap().visible = false; + d.node_mut(NodeId(group)).unwrap().opacity = 0.0; + d.node_mut(NodeId(word)).unwrap().opacity = 0.0; + d.node_mut(image).unwrap().opacity = 0.0; + d.node_mut(NodeId(layer)).unwrap().visible = false; + Ok(()) + }) + .unwrap(); + let (brushes, spots, patterns, gradients, after) = listed(&mut s, json!({})); + assert_eq!((brushes, spots, patterns, gradients), (json!([]), vec![], vec![shown], vec![])); + // Objects counts the listed objects, and every object as before. + assert_eq!((after["spots"].clone(), after["patterns"].clone(), after["gradients"].clone()), (Value::Null, json!(1), Value::Null)); + for k in ["paths", "groups", "textObjects", "images"] { + assert_eq!(after[k], before[k], "{k}: {after}"); + } + let rows = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["sections"][0]["rows"].clone(); + assert_eq!(rows, json!([])); + // Selection Only: an object selected inside a group at an opacity of 0 or a hidden layer paints nothing either. + s.execute("select.set", &json!({"ids": [brushed, ramp]})).unwrap(); + let (brushes, _, _, gradients, after) = listed(&mut s, json!({"selectionOnly": true})); + assert_eq!((brushes, gradients, after["paths"].clone()), (json!([]), vec![], json!(2))); + // Drawn again, the group's brush and the layer's gradient are listed. + s.edit("show", |d, _| { + d.node_mut(NodeId(group)).unwrap().opacity = 1.0; + d.node_mut(NodeId(layer)).unwrap().visible = true; + Ok(()) + }) + .unwrap(); + let (brushes, _, _, gradients, _) = listed(&mut s, json!({"selectionOnly": true})); + assert_eq!((brushes, gradients), (json!(["Charcoal"]), vec![ramp])); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 6a36a11f12..30ac844d5d 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -2,7 +2,7 @@ use vectorcraft_pathops::addons::pathtools as pt; use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, subpath_len, target_paths}; use super::{ - Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json, + Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Rect, Result, Session, SimplifyOptions, SubPath, Value, json, number_or, po, }; @@ -37,10 +37,192 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { out } -fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option { +/// Pieces of curve that one Smooth drag measures against the drag while it searches the segments +/// under it ([`reach_samples`]), shared equally by the curved segments that come within its reach, +/// each of which gets at least one: enough to search 300,000 pt of curve within the default reach +/// of the drag. +const MAX_REACH_PIECES: usize = 1 << 17; + +/// The box around the control points of `s`, which holds all of `s`. +fn control_box(s: CubicBez) -> Rect { + Rect::from_points(s.p0, s.p1).union_pt(s.p2).union_pt(s.p3) +} + +/// The stretches of the drag `pts` (stretch `k` runs from `pts[k]` to the next point; a drag of one +/// point has the point as its only stretch). +fn drag_stretches(pts: &[Point]) -> Vec { + (0..pts.len().saturating_sub(1).max(1)).collect() +} + +/// The stretches of the drag `pts` among `of` whose bounds come within `radius` of `r` on both +/// axes: every stretch within `radius` of a point in `r`, and perhaps a few more. +fn stretches_near(pts: &[Point], of: &[usize], r: Rect, radius: f64) -> Vec { + let r = r.inflate(radius, radius); + of.iter() + .copied() + .filter(|&k| { + let Some(&a) = pts.get(k) else { return false }; + let b = pts.get(k + 1).copied().unwrap_or(a); + a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 + }) + .collect() +} + +/// The distance from `q` to the nearest of the stretches `of` of the drag `pts`. +fn dist_to_stretches(pts: &[Point], of: &[usize], q: Point) -> f64 { + of.iter() + .filter_map(|&k| { + let a = *pts.get(k)?; + Some(dist_to_polyline(&[a, pts.get(k + 1).copied().unwrap_or(a)], q)) + }) + .fold(f64::INFINITY, f64::min) +} + +/// The place on `s` (0 to 1) nearest to the stretches `of` of the drag `pts`, and its distance from +/// them. For each stretch the nearest place is at one of the stretch's ends, at an end of `s`, or +/// where `s` crosses the stretch's line or stops moving toward or away from it. +fn nearest_to_stretches(s: CubicBez, pts: &[Point], of: &[usize]) -> (f64, f64) { + let (c1, c2, c3) = (3.0 * (s.p1 - s.p0), 3.0 * ((s.p2 - s.p1) - (s.p1 - s.p0)), (s.p3 - s.p0) - 3.0 * (s.p2 - s.p1)); + let mut best = (0.5, f64::INFINITY); + for &k in of { + let Some(&a) = pts.get(k) else { continue }; + let b = pts.get(k + 1).copied().unwrap_or(a); + let mut places: Vec = vec![0.0, 1.0, s.nearest(a, 1e-9).t, s.nearest(b, 1e-9).t]; + let d = b - a; + if d.hypot() > 1e-12 { + let (f0, f1, f2, f3) = (d.cross(s.p0 - a), d.cross(c1), d.cross(c2), d.cross(c3)); + places.extend(kurbo::common::solve_cubic(f0, f1, f2, f3)); + places.extend(kurbo::common::solve_quadratic(f1, 2.0 * f2, 3.0 * f3)); + } + for u in places.into_iter().filter(|u| (0.0..=1.0).contains(u)) { + let dist = dist_to_polyline(&[a, b], s.eval(u)); + if dist < best.1 { + best = (u, dist); + } + } + } + best +} + +/// How many curved segments of `pd` may come within `radius` of the drag `pts`. +fn segments_in_reach(pd: &PathData, pts: &[Point], radius: f64) -> usize { + let all = drag_stretches(pts); + pd.subpaths + .iter() + .map(|sp| { + (0..sp.segment_count()) + .filter(|&i| { + !vectorcraft_tools::draw2::segment_is_straight(sp, i) && !stretches_near(pts, &all, control_box(sp.segment(i)), radius).is_empty() + }) + .count() + }) + .sum() +} + +/// Samples along the part of `c` from `t0` to `t1`, in order: (t, whether the point there is within +/// `radius` of the stretches `of` of the drag `pts`). A piece no stretch comes near gives one sample +/// out of reach; the others are halved until their control polygon is no longer than half the +/// reach, or until `budget` pieces have been measured, and then give one sample: in reach at the +/// piece's middle or at its place nearest the drag when either is in reach, otherwise out of reach +/// at its middle. Each piece measures only the stretches near +/// the piece it was cut from, so a long drag along a long curve costs about one pass over the drag +/// per halving, plus the stretches near each piece. +fn reach_samples(c: CubicBez, (t0, t1): (f64, f64), (pts, of): (&[Point], &[usize]), radius: f64, budget: &mut usize, out: &mut Vec<(f64, bool)>) { + *budget = budget.saturating_sub(1); + let s = c.subsegment(t0..t1); + let mid = 0.5 * (t0 + t1); + let near = stretches_near(pts, of, control_box(s), radius); + if near.is_empty() { + out.push((mid, false)); + return; + } + let len = s.p0.distance(s.p1) + s.p1.distance(s.p2) + s.p2.distance(s.p3); + if len <= (radius / 2.0).max(1e-3) || *budget == 0 || mid <= t0 || mid >= t1 { + // The piece's middle, or the place on it nearest the drag when the middle is out of reach. + let sample = if dist_to_stretches(pts, &near, c.eval(mid)) <= radius { + (mid, true) + } else { + let (u, dist) = nearest_to_stretches(s, pts, &near); + if dist <= radius { (t0 + u * (t1 - t0), true) } else { (mid, false) } + }; + out.push(sample); + return; + } + reach_samples(c, (t0, mid), (pts, &near), radius, budget, out); + reach_samples(c, (mid, t1), (pts, &near), radius, budget, out); +} + +/// `sp` cut where the reach `radius` of the drag `pts` crosses its curved segments, and whether each +/// anchor of the result is in reach. An anchor in reach is added in the middle of each stretch of a +/// segment in reach that does not run on from one of the segment's anchors in reach, and an anchor +/// out of reach in the middle of each stretch out of reach between two stretches in reach, so that a +/// stretch in reach is smoothed even when the segment's anchors are not, and the stretches between +/// stay as they are. A segment is searched no more than half the reach apart where it comes within +/// the reach of the drag ([`reach_samples`]), measuring up to `share` pieces of each segment. +fn mark_reached(sp: &SubPath, pts: &[Point], radius: f64, share: usize) -> (SubPath, Vec) { + let near = |q: Point| dist_to_polyline(pts, q) <= radius; let n = sp.anchors.len(); let marked: Vec = sp.anchors.iter().map(|a| near(a.p)).collect(); - if n < 3 || !marked.iter().any(|m| *m) { + let mut places = vec![]; + let all = drag_stretches(pts); + for i in 0..sp.segment_count() { + if vectorcraft_tools::draw2::segment_is_straight(sp, i) { + continue; + } + let c = sp.segment(i); + let (mut samples, mut budget) = (vec![], share); + reach_samples(c, (0.0, 1.0), (pts, &all), radius, &mut budget, &mut samples); + // Stretches of samples alike, in order: (in reach, first, last). + let mut runs: Vec<(bool, usize, usize)> = vec![]; + for (k, s) in samples.iter().enumerate() { + match runs.last_mut() { + Some(r) if r.0 == s.1 => r.2 = k, + _ => runs.push((s.1, k, k)), + } + } + let last = samples.len().saturating_sub(1); + let (start, end) = (marked[i], marked[(i + 1) % n]); + for (j, &(reached, first, to)) in runs.iter().enumerate() { + let mid = samples[(first + to) / 2].0; + let joined = (first == 0 && start) || (to == last && end); + if reached && !joined { + places.push((i, mid)); + } + // A gap with reach on both sides: the segment's anchor or a stretch on each side. + let before = if j == 0 { start } else { runs[j - 1].0 }; + let after = runs.get(j + 1).map_or(end, |r| r.0); + if !reached && before && after { + places.push((i, mid)); + } + } + } + // A place on one of the segment's ends would name that anchor rather than add one. + places.retain(|(_, t)| (1e-5..=1.0 - 1e-5).contains(t)); + if places.is_empty() { + return (sp.clone(), marked); + } + let (out, added) = pt::insert_at(sp, &places); + // The anchors already there keep their marks. A new anchor split off at a sample in reach can + // land a rounding error farther away than the sample. + let mut kept = marked.into_iter(); + let marked = out + .anchors + .iter() + .enumerate() + .map(|(j, a)| if added.contains(&j) { dist_to_polyline(pts, a.p) <= radius + 1e-9 * radius.max(1.0) } else { kept.next().unwrap_or(false) }) + .collect(); + (out, marked) +} + +fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f64, sigma: f64) -> Option { + if sp.anchors.len() < 2 { + return None; + } + let near = &|q: Point| dist_to_polyline(pts, q) <= radius; + // A drag along a segment smooths it even when its anchors are out of reach. + let (sp, marked) = mark_reached(sp, pts, radius, share); + let sp = &sp; + if !marked.iter().any(|m| *m) { return None; } if sp.closed { @@ -115,7 +297,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool for (a, b) in runs.into_iter().rev() { let lo = a.saturating_sub(1); let hi = (b + 1).min(n - 1); - if hi - lo < 2 { + if hi == lo { continue; } let mut piece = out[lo..=hi].to_vec(); @@ -150,16 +332,21 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_TOLERANCE, vectorcraft_tools::draw2::SMOOTH_MAX_TOLERANCE); let sigma = radius * SMOOTH_SPREAD; let st = s.doc()?; - let near = |q: Point| dist_to_polyline(&pts, q) <= radius; let mut plan = vec![]; let (mut before, mut after) = (0, 0); + let mut paths = vec![]; for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) { - let (pd, _) = path_of(&st.doc, id)?; + paths.push((id, path_of(&st.doc, id)?.0)); + } + // Each curved segment in reach gets the same share of the search, whatever order they come in. + let in_reach: usize = paths.iter().map(|(_, pd)| segments_in_reach(pd, &pts, radius)).sum(); + let share = (MAX_REACH_PIECES / in_reach.max(1)).max(1); + for (id, pd) in paths { let mut changed = false; let subs: Vec = pd .subpaths .iter() - .map(|sp| match smooth_subpath(sp, &near, tol, sigma) { + .map(|sp| match smooth_subpath(sp, &pts, radius, share, tol, sigma) { Some(n) if n != *sp && n.anchors.len() >= 2 => { changed = true; n @@ -414,3 +601,37 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "id": ida.0 })) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Searching a curve 1,000,000 pt long under a drag of 1,000 points along it measures no more + /// pieces than the budget it is given, and a search with none left gives one sample. + #[test] + fn searching_a_segment_under_a_drag_stays_within_its_budget() { + let c = CubicBez::new(Point::new(0.0, 0.0), Point::new(300000.0, 0.0), Point::new(700000.0, 0.0), Point::new(1000000.0, 0.0)); + let pts: Vec = (0..1000).map(|i| Point::new(f64::from(i) * 1000.0, 0.0)).collect(); + let all = drag_stretches(&pts); + let mut budget = MAX_REACH_PIECES; + let (mut first, mut second) = (vec![], vec![]); + reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut first); + reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut second); + assert!(first.len() <= MAX_REACH_PIECES, "{} samples", first.len()); + assert_eq!((budget, second.len()), (0, 1), "the budget is spent, so the next search gives one sample"); + } + + /// A small piece of curve is measured only against the few stretches of a drag of 100,000 + /// points that come near it, and a drag of one point has that point as its stretch. + #[test] + fn a_small_piece_is_measured_against_the_stretches_near_it() { + let pts: Vec = (0..100_000).map(|i| Point::new(f64::from(i) * 5.0, 0.0)).collect(); + let all = drag_stretches(&pts); + let near = stretches_near(&pts, &all, Rect::new(1000.0, 3.0, 1004.0, 6.0), 12.0); + assert_eq!(near, (197..=203).collect::>()); + assert!((dist_to_stretches(&pts, &near, Point::new(1002.0, 5.0)) - 5.0).abs() < 1e-12); + let one = [Point::new(10.0, 10.0)]; + assert_eq!(drag_stretches(&one), vec![0]); + assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12); + } +} diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 352e520433..d4a6c60802 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -634,7 +634,9 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - // So do the raster effects of its fills and strokes. + // So do the raster effects of it and of its fills and strokes, by the Transform effects that + // moved it. + carry.hide_skipped(&mut m.appearance.effects); vectorcraft_render::effects::follow_items(&mut m); m.appearance.effects = without_geometry(&m.appearance.effects); Some(m) @@ -662,7 +664,8 @@ pub(crate) fn fill_shape( if carry.moves(&one) { carry.apply(&mut one); } - let moved = one.items.pop().unwrap_or_else(|| item.clone()); + let mut moved = one.items.pop().unwrap_or_else(|| item.clone()); + carry.hide_skipped(moved.effects_mut()); Some((with_path(l, outline.clone()), outline, b, moved)) } @@ -1388,8 +1391,9 @@ fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec Vec<(f64, (Color, f32))> { let at = |t: f64| (t, g.sample_keep_model(t as f32)); let mut v = vec![at(t0)]; - // A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops - // its expansion adds, so the linear mix between columns follows that curve. + // A Perceptual gradient, or one mixed around the hue wheel, curves between its stops: the mesh + // takes a column at each of the stops its expansion adds, so the linear mix between columns + // follows that curve. if !g.method.is_classic() { v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at)); } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 333db1c68f..9154ce7ee5 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -69,8 +69,8 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; -pub(crate) use export::{export_crop, isolated}; -pub use export::{export_doc, export_source}; +pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated}; +pub use export::{export_doc, export_source, unused_option_notes}; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 0974934ded..5863dd4ff7 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -80,10 +80,12 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { /// document's last artboard, the last one; a Paste in Place, in Front or in Back step that names /// `artboards` keeps them, and one that names neither, while the Artboard tool is chosen with /// several artboards selected, pastes on those. A Fit to Artwork Bounds step that names neither -/// `index` nor `indices`, while the Artboard tool is chosen with several artboards selected when the -/// step runs, gets those as its `indices`, which its journal entry records. Otherwise a step that -/// resizes the active artboard ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` (nor -/// `indices`, for Fit to Artwork Bounds) gets that artboard as its `index`. +/// `index` nor `indices`, while the Artboard tool is chosen with two or more artboards selected when +/// the step runs, gets those as its `indices`. Otherwise a step that resizes the active artboard +/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` (nor `indices`, for Fit to Artwork Bounds) +/// gets that artboard as its `index`. A Fit to Artwork Bounds step that still names neither gets +/// `index` 0, the command's default. A Fit to Artwork Bounds step's journal entry records the +/// `indices` or `index` it got. /// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them. fn run_steps( s: &mut Session, @@ -104,7 +106,8 @@ fn run_steps( if s.active().is_some() { s.begin_interaction(label)?; } - let mut params = c.get("params").cloned().unwrap_or(json!({})); + // `"params": null` reads as no params, so the artboards below can still be added. + let mut params = c.get("params").filter(|v| !v.is_null()).cloned().unwrap_or(json!({})); let key = if crate::ON_ACTIVE_ARTBOARD.contains(&id.as_str()) { Some("artboard") } else { @@ -115,12 +118,9 @@ fn run_steps( let names_artboards = id.starts_with("edit.pasteIn") && (params.get("artboards").is_some() || (params.get("artboard").is_none() && !crate::cmd::edit::artboard_tool_picks(s).is_empty())); // A Fit to Artwork Bounds step that names neither `index` nor `indices`, while the Artboard - // tool is chosen with several artboards selected, fits those, as the command does on its own. - let fit_picks = if id == "artboard.fitToArt" && params.get("index").is_none() && params.get("indices").is_none() { - crate::cmd::edit::artboard_tool_picks(s) - } else { - Vec::new() - }; + // tool is chosen with two or more artboards selected, fits those. + let fit_unnamed = id == "artboard.fitToArt" && params.get("index").is_none() && params.get("indices").is_none(); + let fit_picks = if fit_unnamed { crate::cmd::edit::artboard_tool_picks(s) } else { Vec::new() }; if !fit_picks.is_empty() && let Some(o) = params.as_object_mut() { @@ -136,6 +136,15 @@ fn run_steps( { o.insert(key.into(), json!(usize::try_from(a).map_or(last, |a| a.min(last)))); } + // With neither, the step fits the command's default, the first artboard, named here so the + // journal records it. + if fit_unnamed + && params.get("index").is_none() + && params.get("indices").is_none() + && let Some(o) = params.as_object_mut() + { + o.insert("index".into(), json!(0)); + } // The documents with an undo group open, held so that the step's first edit in one copies it. let start = s.active().map(|d| d.uid); let held: Vec<(u64, Arc)> = s.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, st.doc.clone())).collect(); @@ -147,8 +156,9 @@ fn run_steps( grouped.push(step_groups(s, start, &held)); // The step's journal entry records what it took from the preferences or the clock (a // save's date, a new document's), as a command of its own would, and the artboards a Fit to - // Artwork Bounds step took from the Artboard tool. - if (noted != params || !fit_picks.is_empty()) + // Artwork Bounds step took from the Artboard tool, the batch or the command's default, so a + // replay does not fit the artboards the Artboard tool has selected then. + if (noted != params || fit_unnamed) && let Some(c) = c.as_object_mut() { c.insert("params".into(), noted); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 9e95948def..5abe3e879c 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it", has_selection, export::export_selection ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Batch", [], None, - "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer; an artboard.fitToArt step that names neither index nor indices, while the Artboard tool is chosen with several artboards selected when the step runs, gets those as indices instead, and the journal records them)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", + "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer; an artboard.fitToArt step that names neither index nor indices, while the Artboard tool is chosen with two or more artboards selected when the step runs, gets those as indices instead; the journal records the indices or index an artboard.fitToArt step got)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch ), diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 19e496306f..1ae96c6f81 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -15,8 +15,45 @@ use super::{ ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return, }; +/// The keys the export commands take for every format, beside the path, the format and the +/// options `document.formats` lists for each: `richBlack` is Appearance of Black's setting for a +/// raster export, and `params` an exporter plug-in's own. +pub(crate) const EXPORT_PARAMS: [&str; 4] = ["includeArtboardFill", "selectedOnly", "richBlack", "params"]; + +/// A note for each option in `p` that some format takes but the file an export of `cmd` writes in +/// `f` doesn't read (`{format: pdf, quality: 80}`), in the wording of a save's note. An exporter +/// plug-in's format gets none. The notes cover the formats' options only: a key of the command's +/// own ([`EXPORT_PARAMS`]) and an artboard choice that a selection export leaves out get none. +pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec { + let Some(given) = p.as_object() else { return Vec::new() }; + if vectorcraft_plugins::export::by_format(f.id).is_some() { + return Vec::new(); + } + let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k); + let written = super::save::written_as(cmd, f, p); + let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false); + let reads = |k: &str| match (f.id, k) { + // A legacy `.ai` file is not a PDF: it reads its own options only. + ("ai", _) if legacy => super::legacy_ai::reads(k), + // The other `.ai` files don't read the legacy file's options, and `preserveEditing` is + // on for them whatever is given. + ("ai", "preserveEditing") => false, + ("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false, + // An export writes one file: only a save writes each artboard to a file of its own, and + // a native file's `range` names those files. + ("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false, + _ => super::save::reads_option(f, k), + }; + given + .iter() + .filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k)) + .map(|(k, _)| super::save::unused_option_note(k, &written)) + .collect() +} + pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?; + let unused = unused_option_notes("document.serialize", f, p); let expanded = expand_presets(s, "document.serialize", p)?; let p = with_black_output(s, &expanded); let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?; @@ -27,7 +64,8 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { { o.insert("_displayDocTitle".into(), json!(display_doc_title)); } - let enc = encode_all(doc, f.id, &q)?; + let mut enc = encode_all(doc, f.id, &q)?; + enc.add_setting_notes(unused); let files = enc.named(doc, &default_name(doc, f.extensions[0])); let (main, linked) = files.split_at(enc.files.len()); // Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes. @@ -58,6 +96,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?; } let f = writable("document.export", text_param(p, "format", "document.export")?, path)?; + let unused = unused_option_notes("document.export", f, p); let expanded = expand_presets(s, "document.export", p)?; let p = with_black_output(s, &expanded); let (doc, display_doc_title) = source(s, f, &p, "document.export")?; @@ -68,7 +107,8 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { { o.insert("_displayDocTitle".into(), json!(display_doc_title)); } - let enc = encode_all(doc, f.id, &q)?; + let mut enc = encode_all(doc, f.id, &q)?; + enc.add_setting_notes(unused); write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() })) } @@ -315,12 +355,18 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { super::check_not_lossy_overwrite(s.doc()?, path, p, C)?; } let f = writable(C, text_param(p, "format", C)?, path)?; + let unused = unused_option_notes(C, f, p); let expanded = expand_presets(s, C, p)?; let p = with_black_output(s, &expanded); let (d, bounds) = selection(s, C, f.id == "txt")?; // The encoder's warnings (options accepted but not applied, features approximated or left // out), as document.export reports them. - let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?; + let (bytes, mut warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?; + for note in unused { + if !warnings.contains(¬e) { + warnings.push(note); + } + } write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] })) } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 05b6e03551..23168f1e25 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -65,6 +65,18 @@ pub(super) fn named(f: &Format) -> Format { Format { label: "legacy .ai (version 7)", ..*f } } +/// The `.ai` options that only a legacy file reads: a PDF-compatible `.ai` file and a `.ai` file +/// without PDF content don't. +pub(super) const LEGACY_ONLY: [&str; 3] = ["transparentImages", "hiddenObjects", "outlineText"]; + +/// Does an export of a legacy file read option `key`? It reads the version, the file's own +/// options ([`LEGACY_ONLY`]), the artboards to write, and `useArtboards`, which the export reads +/// before [`encode`] runs. It reads none of the other `.ai` options and none of the PDF options +/// that a PDF-compatible `.ai` file takes. +pub(super) fn reads(key: &str) -> bool { + matches!(key, "aiVersion" | "useArtboards") || LEGACY_ONLY.contains(&key) || super::ARTBOARD_PARAMS.contains(&key) +} + /// What a legacy `.ai` does with the images it can't hold (`transparentImages`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SeeThrough { diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 6a2a0986a3..0a306ee1a9 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -317,7 +317,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { let notes = top .filter(|k| !reads_option(format, k)) .chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}"))) - .map(|k| format!("`{k}` was not used: {} files don't read it", written.label)) + .map(|k| unused_option_note(&k, &written)) .collect(); let lossy_source = super::lossy_source(st, p); Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source }) @@ -467,6 +467,12 @@ pub fn reads_option(f: &Format, key: &str) -> bool { f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key))) } +/// The note a save or an export gives for option `key`, as the request names it, which the file it +/// writes (`written`, see [`written_as`]) doesn't read. +pub(super) fn unused_option_note(key: &str, written: &Format) -> String { + format!("`{key}` was not used: {} files don't read it", written.label) +} + /// Formats that carry the whole native document: they lose nothing, and record links relative to /// where they are written. A `.ai` or `.ait` file is a PDF carrying it; EPS carries it in comments. fn is_native(f: &Format) -> bool { @@ -636,7 +642,7 @@ fn without_pdf(f: &Format) -> Format { /// `f` named, in its errors and notes, as the file a save with options `p` writes: a version 7 /// `.ai` as the legacy `.ai`, and a `.ai` or `.ait` with `pdfCompatible: false` as one saved /// without PDF content. -fn written_as(cmd: &str, f: &Format, p: &Value) -> Format { +pub(super) fn written_as(cmd: &str, f: &Format, p: &Value) -> Format { if f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false) { super::legacy_ai::named(f) } else if matches!(f.id, "ai" | "ait") && !bool_or(p, "pdfCompatible", true) { diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs index f3566e82ba..cc1345060d 100644 --- a/crates/engine/src/cmd/fileio/tests_exportas.rs +++ b/crates/engine/src/cmd/fileio/tests_exportas.rs @@ -141,6 +141,137 @@ fn what_the_file_loses_comes_before_a_note_on_the_options() { assert_eq!(r["warnings"], json!(w)); } +/// A session with one artboard and a selected rectangle on it. +fn with_art() -> Session { + let mut s = session(60.0, 40.0, 1); + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + s +} + +/// The warnings of reply `r` that name an option the file doesn't read. +fn unused(r: &Value) -> Vec { + r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect() +} + +/// An option that only another format reads is accepted, and the reply's warnings name it in the +/// words of a save's note; `document.export`, `document.serialize` and `document.exportSelection` +/// give the same note. +#[test] +fn an_option_only_another_format_reads_gets_a_note() { + let (mut s, notes) = (with_art(), unused); + for cmd in ["document.export", "document.serialize", "document.exportSelection"] { + let r = s.execute(cmd, &json!({"format": "pdf", "quality": 80, "outlineText": true})).unwrap(); + assert_eq!( + notes(&r), + ["`outlineText` was not used: PDF files don't read it", "`quality` was not used: PDF files don't read it"], + "{cmd}: {r}" + ); + } + // The format's own options, the keys every export takes and null values get none. + for p in [ + json!({"format": "jpg", "quality": 80, "scale": 2}), + json!({"format": "ai", "compress": true, "compatibility": "1.7"}), + json!({"format": "pdf", "quality": null, "selectedOnly": false, "includeArtboardFill": false, "useArtboards": true}), + json!({"format": "svg", "outlineText": true, "svg": {"decimals": 2}}), + ] { + let r = s.execute("document.export", &p).unwrap(); + assert!(notes(&r).is_empty(), "{p}: {r}"); + } + let r = s.execute("document.serialize", &json!({"format": "png", "compress": true, "marks": {"trim": true}, "svg": {"minify": true}})).unwrap(); + assert_eq!( + notes(&r), + [ + "`compress` was not used: PNG files don't read it", + "`marks` was not used: PNG files don't read it", + "`svg` was not used: PNG files don't read it" + ], + "{r}" + ); + // The note names the kind of file written, as a save's does. + let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false, "quality": 80})).unwrap(); + assert_eq!(notes(&r), ["`quality` was not used: non-PDF-compatible .ai files don't read it"], "{r}"); + let r = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false, "range": "1"})).unwrap(); + assert!(notes(&r).is_empty(), "{r}"); + let r = s.execute("document.export", &json!({"format": "vectorcraft", "artboard": 0})).unwrap(); + assert_eq!(notes(&r), ["`artboard` was not used: VectorCraft files don't read it"], "{r}"); +} + +/// A legacy `.ai` file reads its own options and the artboards to write, not the other `.ai` +/// options or the PDF options. +#[test] +fn a_legacy_ai_export_notes_the_options_of_the_other_ai_files() { + let (mut s, notes) = (with_art(), unused); + let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}})).unwrap(); + assert_eq!( + notes(&r), + ["`compress` was not used: legacy .ai (version 7) files don't read it", "`marks` was not used: legacy .ai (version 7) files don't read it"], + "{r}" + ); + let r = + s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "outlineText": true, "hiddenObjects": "omit", "range": "1"})).unwrap(); + assert!(notes(&r).is_empty(), "{r}"); + let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "useArtboards": false})).unwrap(); + assert!(notes(&r).is_empty(), "{r}"); +} + +/// The `.ai` files other than the legacy one don't read the legacy file's own options. +#[test] +fn a_pdf_compatible_ai_export_notes_the_legacy_options() { + let (mut s, notes) = (with_art(), unused); + let unread = json!({"format": "ai", "aiVersion": "pdf", "outlineText": true, "hiddenObjects": "omit", "transparentImages": "omit"}); + let r = s.execute("document.export", &unread).unwrap(); + assert_eq!( + notes(&r), + [ + "`hiddenObjects` was not used: PDF-compatible .ai files don't read it", + "`outlineText` was not used: PDF-compatible .ai files don't read it", + "`transparentImages` was not used: PDF-compatible .ai files don't read it" + ], + "{r}" + ); +} + +/// `preserveEditing` is always on for a `.ai` file, so an export that gives it gets a note. +#[test] +fn an_ai_export_notes_preserve_editing() { + let (mut s, notes) = (with_art(), unused); + let r = s.execute("document.export", &json!({"format": "ai", "preserveEditing": false, "range": "1"})).unwrap(); + assert_eq!(notes(&r), ["`preserveEditing` was not used: PDF-compatible .ai files don't read it"], "{r}"); +} + +/// An export writes one file: only a save writes each artboard to a file of its own, so an export +/// notes `separateArtboards`, and the `range` that names those files for a native file. +#[test] +fn an_export_notes_the_options_that_only_a_save_reads() { + let (mut s, notes) = (with_art(), unused); + let r = s.execute("document.export", &json!({"format": "ai", "separateArtboards": true, "range": "1"})).unwrap(); + assert_eq!(notes(&r), ["`separateArtboards` was not used: PDF-compatible .ai files don't read it"], "{r}"); + for (format, label) in [("vectorcraft", "VectorCraft"), ("template", "VectorCraft Template")] { + let r = s.execute("document.export", &json!({"format": format, "separateArtboards": true, "range": "1", "compress": true})).unwrap(); + assert_eq!( + notes(&r), + [format!("`range` was not used: {label} files don't read it"), format!("`separateArtboards` was not used: {label} files don't read it")], + "{r}" + ); + } +} + +/// The legacy `.ai` options that the notes go by are the ones the `.ai` option table describes as +/// the legacy file's ("with aiVersion 7"), so an option added to the table is not noted as unread +/// on a legacy export by mistake. +#[test] +fn the_legacy_ai_options_are_the_ones_the_option_table_names() { + let described: Vec<&str> = native::AI_OPTIONS.iter().filter(|o| o.description.starts_with("with aiVersion 7")).map(|o| o.name).collect(); + assert_eq!(described, legacy_ai::LEGACY_ONLY); + for key in described.iter().chain(&ARTBOARD_PARAMS).chain(&["aiVersion", "useArtboards"]) { + assert!(legacy_ai::reads(key), "{key}"); + assert!(native::AI_OPTIONS.iter().any(|o| o.name == *key), "{key} is not a .ai option"); + } + for key in ["compress", "pdfCompatible", "separateArtboards", "editingData", "marks"] { + assert!(!legacy_ai::reads(key), "{key}"); + } +} + #[test] fn note_texts_give_the_losses_first_each_kind_in_order() { use super::encode::{Note, note_texts}; diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 0b560e5e04..b7b0ddde59 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -15,9 +15,8 @@ fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value { s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap() } -fn tmp(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("vc-pdfedit-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); +/// The path of `name` in the test's folder `dir`. +fn tmp(dir: &std::path::Path, name: &str) -> String { dir.join(name).to_string_lossy().to_string() } @@ -114,8 +113,9 @@ fn the_legacy_payload_name_is_accepted() { #[test] fn ai_files_are_pdfs_that_save_and_reopen_editable() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = rich(); - let path = tmp("art.ai"); + let path = tmp(&scratch, "art.ai"); let r = s.execute("document.save", &json!({"path": path, "preserveEditing": false, "range": "1"})).unwrap(); assert_eq!(r["path"], path.as_str()); // As saved (Save stamps the File Info dates). @@ -134,11 +134,11 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() { let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers); // Save As also writes a plain PDF (the document then saves back as PDF); PNG stays an export. - assert_eq!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).unwrap()["format"], "pdf"); - assert!(s.execute("document.save", &json!({"path": tmp("x.png")})).is_err(), "PNG is written with document.export"); + assert_eq!(s.execute("document.save", &json!({"path": tmp(&scratch, "x.pdf")})).unwrap()["format"], "pdf"); + assert!(s.execute("document.save", &json!({"path": tmp(&scratch, "x.png")})).is_err(), "PNG is written with document.export"); // A plain PDF named .ai opens as artwork, without a path to save over. let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap()); - let plain_path = tmp("plain.ai"); + let plain_path = tmp(&scratch, "plain.ai"); std::fs::write(&plain_path, plain).unwrap(); let r = s.execute("document.open", &json!({"path": plain_path})).unwrap(); assert_eq!(r["restored"], false); @@ -149,9 +149,10 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() { /// As does, leaving the document's own path alone. #[test] fn exports_write_ai_as_save_as_does() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = rich(); let before = comparable(&s.doc().unwrap().doc); - let path = tmp("exported.ai"); + let path = tmp(&scratch, "exported.ai"); let r = s.execute("document.export", &json!({"path": path})).unwrap(); assert_eq!(r["format"], "ai", "{r}"); let bytes = std::fs::read(&path).unwrap(); @@ -225,12 +226,13 @@ fn ait_templates_open_their_document_untitled() { /// from Template opens it as an untitled copy of the document, layers and art included. #[test] fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = rich(); s.execute("layer.new", &json!({"name": "Notes"})).unwrap(); s.execute("shape.ellipse", &json!({"x": 20, "y": 120, "width": 40, "height": 30})).unwrap(); let before = comparable(&s.doc().unwrap().doc); assert_eq!(before.layers.len(), 2); - let path = tmp("brochure template.ait"); + let path = tmp(&scratch, "brochure template.ait"); let r = s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap(); assert_eq!(r["format"], "ait", "{r}"); let bytes = std::fs::read(&path).unwrap(); @@ -253,6 +255,7 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() { /// writes a `.ait`. #[test] fn save_as_template_names_its_file_after_the_template_format() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = rich(); for (name, format, saved) in [ ("card.png", None, "card.vctemplate"), @@ -261,7 +264,7 @@ fn save_as_template_names_its_file_after_the_template_format() { ("card.pdf", Some("ait"), "card.ait"), ("card v1.2", None, "card v1.2.vctemplate"), ] { - let (typed, want) = (tmp(&format!("named/{name}")), tmp(&format!("named/{saved}"))); + let (typed, want) = (tmp(&scratch, &format!("named/{name}")), tmp(&scratch, &format!("named/{saved}"))); let _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap()); let _ = std::fs::remove_file(&typed); let _ = std::fs::remove_file(&want); @@ -273,7 +276,7 @@ fn save_as_template_names_its_file_after_the_template_format() { assert!(ait || vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "{name}: a native template"); assert!(!std::path::Path::new(&typed).exists(), "{name}: nothing written under the typed name"); } - for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp("card.ait")}))] { + for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp(&scratch, "card.ait")}))] { let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}"); } @@ -284,8 +287,9 @@ fn save_as_template_names_its_file_after_the_template_format() { /// nothing writes keeps its own error. #[test] fn exports_of_an_ait_name_save_as_template() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = rich(); - let path = tmp("exported.ait"); + let path = tmp(&scratch, "exported.ait"); let _ = std::fs::remove_file(&path); for (cmd, p) in [ ("document.export", json!({"path": path})), @@ -305,8 +309,9 @@ fn exports_of_an_ait_name_save_as_template() { /// file; `aiVersion` "pdf" writes the PDF-compatible template. #[test] fn save_as_template_refuses_a_legacy_ait() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = rich(); - let path = tmp("legacy-ait/card.ait"); + let path = tmp(&scratch, "legacy-ait/card.ait"); let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf(); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -424,7 +429,8 @@ fn an_ai_of_the_arts_bounds_carries_no_editing_data() { let mut s = art_outside_the_artboards(); let before = comparable(&s.doc().unwrap().doc); let left_out = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w == super::save::AI_EDITING_NEEDS_THE_ARTBOARDS); - let path = tmp("art-bounds.ai"); + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); + let path = tmp(&scratch, "art-bounds.ai"); for extra in [json!({}), json!({"range": "2"}), json!({"editingData": false}), json!({"path": path})] { let mut params = json!({"format": "ai", "useArtboards": false}); params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); @@ -670,6 +676,7 @@ fn editing_copy(bytes: &[u8]) -> vectorcraft_doc::Document { /// writes the copy; the version 7 `.ai` keeps the compound path and the clipping group. #[test] fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = see_through_session(); let before = (*s.doc().unwrap().doc).clone(); // The mask's art is one path, and it reads back as that path. @@ -682,7 +689,7 @@ fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() { assert_eq!(see_through_outline(&editing_copy(&bytes)), want, "{opts}"); } // Each artboard in a file of its own: the first holds all the art. - let path = tmp("see-through.ai"); + let path = tmp(&scratch, "see-through.ai"); let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap(); let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); assert_eq!(files.len(), 3, "{r}"); @@ -812,7 +819,8 @@ fn image_outline(doc: &vectorcraft_doc::Document) -> Vec { /// the linked image keeps its path and its path relative to the document's folder. #[test] fn images_come_back_from_the_editing_copy() { - let dir = tmp("ai-images"); + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); + let dir = tmp(&scratch, "ai-images"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(format!("{dir}/art")).unwrap(); let png = |w: u32, h: u32, f: &dyn Fn(u32, u32) -> [u8; 4]| { @@ -882,6 +890,7 @@ fn type_outline(doc: &vectorcraft_doc::Document) -> Vec { /// for other apps alone as type, with every save option that writes the copy. #[test] fn type_comes_back_from_the_editing_copy() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = Session::new(); s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Hello\nWorld", "size": 24})).unwrap(); @@ -889,7 +898,7 @@ fn type_comes_back_from_the_editing_copy() { // The new type is selected. s.execute("object.rotate", &json!({"angle": 30})).unwrap(); s.execute("text.create", &json!({"x": 20, "y": 120, "text": "Area type in a frame", "area": {"width": 120, "height": 60}})).unwrap(); - let path = tmp("type.ai"); + let path = tmp(&scratch, "type.ai"); s.execute("document.save", &json!({"path": path})).unwrap(); let before = type_outline(&s.doc().unwrap().doc); assert_eq!(before.len(), 3, "{before:#?}"); @@ -971,6 +980,7 @@ fn gradient_session() -> Session { /// gradient. #[test] fn gradients_and_patterns_come_back_from_the_editing_copy() { + let scratch = vectorcraft_testkit::TempDir::new("pdfedit"); let mut s = gradient_session(); let before = (*s.doc().unwrap().doc).clone(); let want = paint_outline(&before); @@ -990,7 +1000,7 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() { let copy = editing_copy(&b64(&r)); assert_eq!(paint_outline(©), want, "{opts}"); } - let path = tmp("gradients.ai"); + let path = tmp(&scratch, "gradients.ai"); let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap(); let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); assert_eq!(files.len(), 3, "{r}"); diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs index 86cb84cfcb..d3d8a988f8 100644 --- a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs @@ -59,7 +59,7 @@ fn live_texts(s: &Session) -> Vec { #[test] fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() { let bytes = live_text_pdf(); - let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text"); + let dir = vectorcraft_testkit::TempDir::new("pdf-place-live-text"); let path = dir.join("text.pdf"); std::fs::write(&path, &bytes).unwrap(); for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] { @@ -71,7 +71,6 @@ fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() { assert_eq!(live_texts(&s), ["Second page"], "{r}"); assert!(!r["warnings"].to_string().contains("outlines"), "{r}"); } - std::fs::remove_dir_all(dir).unwrap(); } #[test] diff --git a/crates/engine/src/cmd/fileio/tests_readonly.rs b/crates/engine/src/cmd/fileio/tests_readonly.rs index badf82a8cf..ae9762efc3 100644 --- a/crates/engine/src/cmd/fileio/tests_readonly.rs +++ b/crates/engine/src/cmd/fileio/tests_readonly.rs @@ -9,7 +9,7 @@ use crate::{EngineError, READ_ONLY_LOCATION, Session}; #[test] fn readonly_error_is_typed_and_other_writers_keep_their_error() { - let dir = vectorcraft_testkit::temp_dir("save-refusal"); + let dir = vectorcraft_testkit::TempDir::new("save-refusal"); let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned(); for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] { let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); @@ -26,7 +26,6 @@ fn readonly_error_is_typed_and_other_writers_keep_their_error() { } assert!(!std::path::Path::new(&path).exists()); } - std::fs::remove_dir_all(dir).unwrap(); } #[test] diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 005427ded8..7c8f664cf3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -1136,37 +1136,6 @@ fn font_list(_: &mut Session, p: &Value) -> Result { } } -/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols' -/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the -/// art of another symbol takes one more round, so there is at most one round per symbol. -fn resolve_art_inline(doc: &mut Document) { - for _ in 0..=doc.symbols.len() { - let names: BTreeSet = - art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect(); - let bounds: BTreeMap> = names - .into_iter() - .map(|name| { - let b = doc.symbol_art_bounds(&name); - (name, b) - }) - .collect(); - let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds))); - let mut changed = false; - edit_art_texts_of(doc, &stale, &mut |t| { - for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { - if let Some(b) = bounds.get(&a.symbol) { - a.bounds = *b; - } - } - refresh_bounds(t); - changed = true; - }); - if !changed { - break; - } - } -} - pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); #[cfg(not(target_arch = "wasm32"))] @@ -1188,8 +1157,7 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { d.change_unsaved(|doc| { let doc = std::sync::Arc::make_mut(doc); edit_art_texts_of(doc, &|_| true, &mut refresh_bounds); - resolve_art_inline(doc); - doc.resolve_inline_art(); + doc.resolve_inline_art_with(&mut refresh_bounds); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { refresh_bounds(t); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 0f72b066ab..f4ec784af3 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 090a7cb46c..9b41eea001 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)", has_doc, edit_gradient ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)", has_doc, set_gradient_geom ), @@ -126,7 +126,11 @@ fn parse_kind(k: &str) -> Parsed { fn method_param(p: &Value) -> Parsed> { match p.get("method") { None | Some(Value::Null) => Ok(None), - Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()), + Some(v) => v + .as_str() + .and_then(GradientMethod::parse) + .map(Some) + .ok_or_else(|| "`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise".into()), } } diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index 29de2210ae..037033ea8a 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -215,7 +215,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { o.insert("stroke".into(), json!(stroke)); o.insert("kind".into(), json!(g.kind.label().to_lowercase())); o.insert("stops".into(), stops_json(&g.stops)); - o.insert("method".into(), json!(g.method.label().to_lowercase())); + o.insert("method".into(), json!(g.method.id())); // A freeform preset takes no spread (paint.editGradient refuses one). if g.kind != vectorcraft_color::GradientKind::Freeform { o.insert("spread".into(), json!(g.spread.label().to_lowercase())); diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index c45bbf5a67..91de779096 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -46,8 +46,8 @@ use generate::generate; use series::default_series_color; pub(crate) use series::{ capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, - capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_object_opacity_differs, - shown_paint, + capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, + series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 99a7ec084b..4e2d4a8bda 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -103,6 +103,25 @@ fn series_members(doc: &Document) -> HashMap { out } +/// The objects directly in a series group among `roots` and inside them, for an edit that reaches graph series marks +/// without naming them. They may include objects of groups that only look like series groups; the capture functions +/// check each one. +pub(crate) fn series_marks_in(doc: &Document, roots: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for id in roots { + if doc.parent_of(*id).and_then(|p| doc.node(p)).is_some_and(|g| g.extras().series_index.is_some()) { + out.push(*id); + } + let Some(root) = doc.node(*id) else { continue }; + root.walk(&mut |n| { + if n.extras().series_index.is_some() { + out.extend(n.children().into_iter().flatten().map(|c| c.id)); + } + }); + } + out +} + /// Group Selection on a graph series targets its constituent marks and legend swatch, not a /// group-level appearance which the generated art does not inherit. Other ids pass through. pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 6a111fb700..b1a491424e 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -2209,6 +2209,208 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { assert_eq!(spec(&s, id).series_paints[0].fill, red); } +/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one. +fn every_case(cases: impl IntoIterator, case: impl Fn(&T)) { + let failed: Vec = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect(); + assert!(failed.is_empty(), "failed cases: {failed:?}"); +} + +/// The top fill paint of `mark`. +fn fill_of(s: &Session, mark: NodeId) -> Paint { + s.doc().unwrap().doc.node(mark).unwrap().appearance.fill_paint() +} + +/// A stroke weight set with `stroke.set`, or stepped with `stroke.weightUp` or `stroke.weightDown`, on a series mark is +/// stored on the graph with the mark's stroke paint, and undo takes it back with its edit. +#[test] +fn a_stroke_weight_set_on_a_series_mark_is_kept() { + let cases = [("stroke.set", json!({"weight": 7}), 7.0), ("stroke.weightUp", json!({}), 3.0), ("stroke.weightDown", json!({}), 1.0)]; + every_case(cases, |(cmd, params, weight)| { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + p["ids"] = json!([marks[1].0]); + s.execute(cmd, &p).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + let (fill, stroke) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(stroke.clone()), Some(*weight)), "{cmd}"); + series_0_is(&mut s, id, (&fill, 0.4, &stroke, *weight), &was); + }); +} + +/// A gradient made on a series mark's fill with `paint.editGradient` is stored on the graph, every mark of the series has +/// that fill after the graph is drawn again, and undo takes it back with its edit. +#[test] +fn a_gradient_edit_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.editGradient", &json!({"ids": [marks[0].0], "kind": "linear"})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert!(matches!(fill, Paint::Gradient(_)), "{fill:?}"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); +} + +/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and +/// every mark of the series has that fill, placement included, after the graph is drawn again. +#[test] +fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { + let (mut s, id, _, marks) = column_marks(); + // `paint.setFill` stores the gradient before it is placed. + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let placed = fill_of(&s, marks[0]); + assert_ne!(placed, fill, "the command places the gradient"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); +} + +/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. +#[test] +fn a_freeform_gradient_edit_on_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]); + // `paint.setFill` stores the gradient before its point is recolored. + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap(); + let edited = fill_of(&s, marks[0]); + assert_ne!(edited, fill, "the command recolors the point"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0)); +} + +/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, +/// a color filter or Recolor Artwork is stored on the graph, and undo takes it back with its edit. +#[test] +fn a_recolored_series_mark_is_kept() { + let cases = [ + ("paint.invert", json!({}), false), + ("paint.complement", json!({}), false), + ("paint.invert", json!({}), true), + ("paint.complement", json!({}), true), + ("edit.colors.invert", json!({}), false), + ("edit.colors.toGrayscale", json!({}), false), + ("addon.color.filter", json!({"effect": "addon.color.invert"}), false), + ("recolor.apply", json!({"map": {"#3366cc": "#00ff00"}}), false), + ]; + every_case(cases, |(cmd, params, item)| { + let (mut s, id, marks) = stored_red_stroke(); + // Not the gray the series starts in: Complement leaves a gray as it is. + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#3366cc"})).unwrap(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + if *cmd == "recolor.apply" { + // Recolor Artwork works on the selection. + s.execute("select.set", &json!({"ids": [marks[0].0]})).unwrap(); + } else { + p["ids"] = json!([marks[0].0]); + } + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + let was_fill = fill_of(&s, marks[0]); + s.execute(cmd, &p).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_ne!(fill, was_fill, "{cmd} recolors the fill"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()), "{cmd} item {item}"); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); + }); +} + +/// The ink limit of Print Ink Fixes on a series mark's fill is stored on the graph. +#[test] +fn an_ink_limit_on_a_series_mark_is_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap(); + let id = graph(&mut s, "column"); + let mark = series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap()[0].id; + // `paint.setFill` stores the heavy color before the limit lowers it. + s.execute("paint.setFill", &json!({"ids": [mark.0], "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap(); + let heavy = fill_of(&s, mark); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(heavy.clone())); + s.execute("addon.color.printFixes", &json!({"ids": [mark.0], "inkLimit": 300})).unwrap(); + let fill = fill_of(&s, mark); + assert_ne!(fill, heavy, "the limit lowers the ink"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); +} + +/// A recolor of a whole graph, or of one series group, stores the new fill of each series it reaches, and the marks have +/// it after the graph is drawn again. +#[test] +fn recoloring_a_graph_or_a_series_group_stores_its_marks_fills() { + let cases = [("edit.colors.invert", true), ("edit.colors.invert", false), ("paint.invert", true), ("paint.invert", false)]; + every_case(cases, |(cmd, whole)| { + let (mut s, id, gray, _) = column_marks(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (group, other) = (series(&n, 0).id, series(&n, 1).children().unwrap()[0].clone()); + let target = if *whole { id } else { group }; + s.execute(cmd, &json!({"ids": [target.0]})).unwrap(); + let fill = marks_of(&s, id, 0).last().unwrap().0.clone(); + assert_ne!(fill, gray, "{cmd} recolors the series"); + let kept = spec(&s, id).series_paints; + assert_eq!(kept[0].fill, Some(fill.clone()), "{cmd} whole {whole}"); + let other_now = fill_of(&s, other.id); + if *whole { + assert_ne!(other_now, other.appearance.fill_paint()); + assert_eq!(kept[1].fill, Some(other_now.clone())); + } else { + assert_eq!(other_now, other.appearance.fill_paint(), "the other series is left as it was"); + assert!(kept.get(1).is_none_or(|p| p.fill.is_none()), "{kept:?}"); + } + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); + assert!(marks_of(&s, id, 1).iter().all(|m| m.0 == other_now), "{:?}", marks_of(&s, id, 1)); + }); +} + +/// A recolor that changes several marks of one series to different fills stores the frontmost mark's fill, whatever +/// order the ids are given in. +#[test] +fn recoloring_several_marks_of_a_series_stores_the_frontmost_ones_fill() { + let cases = + [("edit.colors.invert", false), ("paint.invert", true)].into_iter().flat_map(|c| [[0, 1, 2], [2, 1, 0], [1, 2, 0]].map(|o| (c.0, c.1, o))); + every_case(cases, |(cmd, item, order)| { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let mut p = json!({"ids": order.map(|i| marks[i].0)}); + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + s.execute(cmd, &p).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill_of(&s, marks[2])), "{cmd} {order:?}"); + }); +} + +/// Blend Horizontally across a series' three marks stores the fill it gives the middle one on the graph. +#[test] +fn blending_a_series_marks_fills_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let was = spec(&s, id).series_paints; + s.execute("select.set", &json!({"ids": [marks[0].0, marks[1].0, marks[2].0]})).unwrap(); + s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(); + let mid = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); + assert_ne!(Some(&mid), was[0].fill.as_ref()); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(mid.clone())); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&mid, 0.4, &red, 2.0), &was); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { @@ -2276,7 +2478,7 @@ fn series_colours_survive_native_save_open_and_export() { let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id; s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap(); s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap(); - let dir = vectorcraft_testkit::temp_dir("graph-palette"); + let dir = vectorcraft_testkit::TempDir::new("graph-palette"); let path = dir.join("palette.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); @@ -2690,7 +2892,8 @@ fn series_types_survive_save_and_open_and_older_files_have_none() { s.execute("file.new", &json!({})).unwrap(); let id = graph(&mut s, "column"); s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-kinds"); + let path = dir.join("kinds.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]); @@ -2818,7 +3021,8 @@ fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() { let id = two_scale_graph(&mut s); s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap(); s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-axes"); + let path = dir.join("axes.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); let g = spec(&s, id); @@ -3020,7 +3224,8 @@ fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() { &json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}), ) .unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-ticks"); + let path = dir.join("ticks.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); let g = spec(&s, id); @@ -3259,7 +3464,8 @@ fn designs_and_markers_survive_save_and_open() { save_design(&mut s, "Pill"); let id = graph(&mut s, "line"); s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-designs"); + let path = dir.join("designs.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"])); @@ -3657,7 +3863,8 @@ fn column_designs_survive_save_and_open_and_follow_their_design() { assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series"); let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap(); assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"})); - let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-column-designs"); + let path = dir.join("columns.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale }; @@ -4179,7 +4386,8 @@ fn legends_in_wedges_put_the_series_labels_inside_the_pie() { s.execute("graph.setType", &json!({"legend": true})).unwrap(); assert!(!spec(&s, id).pie_legend_in_wedges); assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err()); - let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-pie"); + let path = dir.join("pie.vectorcraft"); s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap(); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); @@ -4320,7 +4528,8 @@ fn the_legend_across_top_runs_in_rows_above_the_plot() { assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}"); let xs: Vec = (0..6).map(|i| swatch(&s, i).x1).collect(); assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}"); - let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-top"); + let path = dir.join("top.vectorcraft"); s.execute("graph.setType", &json!({"dropShadow": true})).unwrap(); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); @@ -4439,7 +4648,8 @@ fn filled_lines_are_bands_in_the_series_colour() { s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap(); assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); - let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-filled"); + let path = dir.join("filled.vectorcraft"); s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap(); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); @@ -4566,7 +4776,8 @@ fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None); s.execute("edit.redo", &json!({})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-label-anti-alias"); + let path = dir.join("labels.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong)); @@ -4609,7 +4820,8 @@ fn formatting_one_label_keeps_that_kind_of_label() { assert_eq!(look("0").2, "#0000ff", "every value axis number"); assert_eq!(look("0").1, 9.0); // Kept through save and open; nothing written when unset. - let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-labels"); + let path = dir.join("labels.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0); @@ -4778,7 +4990,8 @@ fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() { let v = text_id(&s, col, "Axes", "4"); s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap(); - let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft"); + let dir = vectorcraft_testkit::TempDir::new("graph-label-paras"); + let path = dir.join("labels.vectorcraft"); s.execute("document.save", &json!({"path": path})).unwrap(); s.execute("document.open", &json!({"path": path})).unwrap(); s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs index 14264fc809..db3e929952 100644 --- a/crates/engine/src/cmd/inline.rs +++ b/crates/engine/src/cmd/inline.rs @@ -22,16 +22,14 @@ pub fn specs() -> Vec { } /// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols, -/// resolve the inline art of every text again (only texts whose art changed are touched). +/// resolve the inline art of every text again, in the layers and in the art of symbols, graph +/// designs, pattern tiles and opacity masks, and lay out again the texts whose art changed (a +/// symbol's type before the type that shows that symbol inline). pub(crate) fn refresh(before: &Document, d: &mut Document) { if before.symbols.is_empty() && d.symbols.is_empty() { return; } - for id in d.resolve_inline_art() { - if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - refresh_bounds(t); - } - } + d.resolve_inline_art_with(&mut refresh_bounds); } fn insert_inline(s: &mut Session, p: &Value) -> Result { @@ -85,7 +83,7 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result { } let caret = s.edit("Insert Inline Symbol", |d, _| { let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) }; - art.bounds = d.symbol_art_bounds(&symbol); + art.bounds = d.inline_art_bounds(&symbol); let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let style = edit::insertion_style(&t.runs, a, b); let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]); diff --git a/crates/engine/src/cmd/interactivity.rs b/crates/engine/src/cmd/interactivity.rs index 4bcc123dcf..359c2d00d6 100644 --- a/crates/engine/src/cmd/interactivity.rs +++ b/crates/engine/src/cmd/interactivity.rs @@ -3,10 +3,14 @@ //! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as //! event attributes (`onclick="…"`) and the files as `