diff --git a/Cargo.lock b/Cargo.lock index ce5569cde9..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", @@ -6477,6 +6476,7 @@ dependencies = [ "vectorcraft-text", "vectorcraft-three-d", "vectorcraft-trace", + "wat", ] [[package]] @@ -6665,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..ffa1d2a5e8 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -96,26 +96,26 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a x11rb = { version = "0.13", default-features = false, features = ["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 0391b7534b..4827772444 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -108,10 +108,37 @@ impl eframe::App for App { self.app.start_pending_update(); #[cfg(target_os = "macos")] super::mac_cursor::release(); + #[cfg(not(any(target_os = "macos", windows)))] + hide_before_teardown(self.winit_window.as_deref()); end_before_teardown(self.frames); } } +/// The main window as [`hide_before_teardown`] sees it. +#[cfg(not(any(target_os = "macos", windows)))] +pub(crate) trait QuittingWindow { + fn hide(&self); +} + +#[cfg(not(any(target_os = "macos", windows)))] +impl QuittingWindow for winit::window::Window { + fn hide(&self) { + self.set_visible(false); + } +} + +/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits +/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and +/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland +/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and +/// cannot hide a Wayland window. +#[cfg(not(any(target_os = "macos", windows)))] +pub(crate) fn hide_before_teardown(window: Option<&W>) { + if let Some(window) = window { + window.hide(); + } +} + impl App { /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a /// focused, shown window whose windows had input waits for the renderer and the next frame, diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index c5ef4d5579..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()) } } @@ -1504,6 +1537,16 @@ mod portable { assert_eq!(finish(true), Duration::ZERO); } + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn quitting_waits_for_the_paste_checks_handle_as_long_as_its_reads_last() { + // The Paste check's handle is one of these: when the app quits, its unanswered X11 read + // ends `X11_READ_LIMIT` after it was sent, and an idle worker stops within `LOAD_WAIT`. + // Quitting waits for that handle until both are over. + assert!(vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT >= finish(false) + super::super::loads::LOAD_WAIT); + assert!(vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT >= finish(true) + super::super::loads::LOAD_WAIT); + } + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] #[test] fn a_bitmap_paste_waits_for_the_owner_off_the_ui_thread() { @@ -1646,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(); @@ -1695,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); @@ -1727,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 { @@ -1809,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 { @@ -1816,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/host_services.rs b/apps/vectorcraft/src/host_services.rs index 03a9b8848c..94419b1049 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -393,8 +393,9 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Its calls are marked for the hang report (`hang_watch`). system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard - // owner that never answers holds a read for up to 4 s. Windows only asks which formats the - // clipboard holds and macOS asks the pasteboard server, so they check in line. + // owner that never answers holds a text read for up to 4 s for each format it tries, and + // quitting waits up to `CLIPBOARD_PROBE_STOP_WAIT` for that thread. Windows only asks which + // formats the clipboard holds and macOS asks the pasteboard server, so they check in line. // The probe's handle is made on its own thread, from the window (`Send`), not its raw display. clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(move || clipboard::system_clipboard(clip_probe)) as ClipboardProbeFactory), // Help → Discord / website / GitHub, the Discord button, About and Home links. 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 c2b40e37e6..cd3ea7cd36 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -53,6 +53,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; @@ -87,6 +89,8 @@ mod titlebar; use app::bring_window_forward; use app::native_input_hook; use app::open_or_hold; +#[cfg(all(test, not(any(target_os = "macos", windows))))] +use app::{QuittingWindow, hide_before_teardown}; use host_services::services; #[cfg(all(test, feature = "wgpu"))] use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes}; @@ -116,7 +120,8 @@ struct App { frames: u64, /// Keeps the custom title bar's window borderless (Windows, #1758). window_frame: Option, - /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635). + /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635), + /// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`). winit_window: Option>, /// A saved or fitted geometry asked for maximize once the window is mapped (Linux). pending_maximize: bool, 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/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 6e61cbe31f..6ae80944a6 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -460,3 +460,21 @@ fn the_linux_desktop_entry_opens_every_readable_format() { } } } + +/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose +/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go. +#[cfg(not(any(target_os = "macos", windows)))] +#[test] +fn quitting_hides_the_window_before_the_clipboards_let_go() { + struct Window(std::cell::Cell); + impl QuittingWindow for Window { + fn hide(&self) { + self.0.set(self.0.get() + 1); + } + } + let window = Window(std::cell::Cell::new(0)); + hide_before_teardown(Some(&window)); + assert_eq!(window.0.get(), 1, "the window was not hidden"); + // Without a window (none was made), there is nothing to hide. + hide_before_teardown::(None); +} diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 46dcdb52ba..c5e5bc5bea 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -531,7 +531,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/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 f4877b0a49..d170bd606f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1381,6 +1381,14 @@ pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Op layout_spine(keys, spec) } +/// The line the steps of a blend of `keys` are placed along: [`blend_spine`]'s path for a blend +/// with a spine, and the straight lines between the key centers, in key order, for a blend made +/// without one ([`BlendSpec::no_spine`]). For a blend without a spine the line places the steps +/// only; it is not a spine and is not drawn as one. `None` with fewer than two keys. +pub fn blend_layout_line(keys: &[Arc], spec: &BlendSpec) -> Option { + layout_spine(keys, spec).map(|(path, _)| path) +} + /// The spine `spec` stores, unless the blend was made without one. fn stored_spine(spec: &BlendSpec) -> Option<&PathData> { spec.spine.as_ref().filter(|_| !spec.no_spine) @@ -1852,6 +1860,28 @@ mod tests { assert_eq!(moved.subpaths[0].anchors[2].p, Point::new(305.0, 5.0)); } + #[test] + fn a_blend_without_a_spine_has_a_layout_line_through_its_key_centers() { + let at = + |x: f64, y: f64| Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x - 5.0, y - 5.0, x + 5.0, y + 5.0)), Appearance::default())); + let line_of = |path: &PathData| path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect::>(); + // A stored spine is ignored once the blend is made without one. + let stored = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(300.0, 50.0)], false)); + let spec = BlendSpec { no_spine: true, spine: Some(stored), ..Default::default() }; + for keys in [vec![at(10.0, 20.0), at(110.0, 20.0)], vec![at(10.0, 20.0), at(110.0, 20.0), at(110.0, 220.0), at(-40.0, 0.0)]] { + assert!(blend_spine(&keys, &spec).is_none(), "still no spine"); + let line = blend_layout_line(&keys, &spec).unwrap(); + assert_eq!(line.subpaths.len(), 1); + assert!(!line.subpaths[0].closed); + let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); + assert_eq!(line_of(&line), centers, "in key order"); + } + // With a spine, the layout line is the spine. + let keys = vec![key(0.0), key(100.0), key(300.0)]; + assert_eq!(blend_layout_line(&keys, &BlendSpec::default()), blend_spine(&keys, &BlendSpec::default()).map(|(p, _)| p)); + assert!(blend_layout_line(&keys[..1], &spec).is_none(), "one key"); + } + #[test] fn steps_follow_a_bent_spine() { let keys = vec![key(0.0), key(100.0)]; diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 391fc39633..e89d63cb54 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -166,24 +166,180 @@ fn planar(d: &mut tiff::decoder::Decoder>) -> bool { /// that many samples, which includes images whose planes together need more than the decoder's /// limit (256 MB of samples). pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { + Some(match read_samples_at_depth(d, n, per)? { + Samples::Eight(v) => v, + Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + }) +} + +/// Samples of an image, pixel by pixel, at the depth its file stores them. +#[derive(Clone, Debug, PartialEq)] +enum Samples { + Eight(Vec), + Sixteen(Vec), +} + +impl Samples { + fn as_slice(&self) -> SampleSlice<'_> { + match self { + Self::Eight(v) => SampleSlice::Eight(v), + Self::Sixteen(v) => SampleSlice::Sixteen(v), + } + } +} + +/// [`Samples`], borrowed. +#[derive(Clone, Copy, Debug)] +enum SampleSlice<'a> { + Eight(&'a [u8]), + Sixteen(&'a [u16]), +} + +/// [`read_samples`], keeping 16-bit samples at 16 bits. +fn read_samples_at_depth(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option { let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); let layout = d.read_image_to_buffer(&mut buf).ok()?; - let data = match buf { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; + match buf { + tiff::decoder::DecodingResult::U8(v) => chunky(v, layout.planes, n, per).map(Samples::Eight), + tiff::decoder::DecodingResult::U16(v) => chunky(v, layout.planes, n, per).map(Samples::Sixteen), + _ => None, + } +} + +/// `data`, the samples of `n` pixels, `per` to a pixel, stored in `planes` planes, as the samples +/// pixel by pixel; `None` when it doesn't hold that many samples or a plane per sample. +fn chunky(data: Vec, planes: usize, n: usize, per: usize) -> Option> { if Some(data.len()) != n.checked_mul(per) { return None; } - if layout.planes <= 1 { + if planes <= 1 { return Some(data); } - if layout.planes != per { + if planes != per { return None; } - let planes: Vec<&[u8]> = data.chunks_exact(n).collect(); - Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect()) + let planes: Vec<&[T]> = data.chunks_exact(n).collect(); + Some((0..n).flat_map(|i| planes.iter().filter_map(move |p| p.get(i).copied())).collect()) +} + +/// The color space an ICC profile's header names (bytes 16 to 19, such as `RGB `, `GRAY` or +/// `CMYK`); `None` when `icc` is shorter than a header. +pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> { + if icc.len() < 128 { + return None; + } + icc.get(16..20)?.try_into().ok() +} + +/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one. +fn tiff_icc(d: &mut tiff::decoder::Decoder>) -> Option> { + d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty()) +} + +/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own +/// limits, whatever the size of the image; `None` for other images and TIFFs without one. +pub fn tiff_icc_profile(bytes: &[u8]) -> Option> { + let (_, mut d) = tiff(bytes)?; + tiff_icc(&mut d) +} + +/// The ICC profile of a JPEG, put together from all its numbered `ICC_PROFILE` APP2 segments; +/// `None` without one. +fn jpeg_icc(bytes: &[u8]) -> Option> { + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default()); + d.decode_headers().ok()?; + d.icc_profile().filter(|icc| !icc.is_empty()) +} + +/// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in. +fn cmyk_icc(icc: Option>) -> Option> { + icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK")) +} + +/// The CMYK samples of a CMYK image at the depth its file stores them, and its ICC profile. +struct CmykSource { + width: u32, + height: u32, + samples: Samples, + icc: Option>, +} + +/// The samples of CMYK TIFF `bytes` (8 or 16 bits), their inks together or one plane per ink, with +/// the TIFF's CMYK ICC profile; `None` for other images and ones larger than [`MAX_CMYK_PIXELS`]. +fn tiff_source(bytes: &[u8]) -> Option { + let (kind, mut d) = tiff(bytes)?; + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { + return None; + } + let (width, height) = d.dimensions().ok()?; + let n = usize::try_from(pixels(width, height)?).ok()?; + let icc = cmyk_icc(tiff_icc(&mut d)); + let samples = read_samples_at_depth(&mut d, n, 4)?; + Some(CmykSource { width, height, samples, icc }) +} + +/// The ink amounts of a four-component JPEG, 8 bits each, with its CMYK ICC profile. +fn jpeg_source(bytes: &[u8]) -> Option { + let Inks { width, height, data } = jpeg_inks(bytes)?; + Some(CmykSource { width, height, samples: Samples::Eight(data), icc: cmyk_icc(jpeg_icc(bytes)) }) +} + +/// A Deflate-compressed CMYK TIFF of `width` × `height` pixels of `samples` (inks together, 8 or 16 +/// bits as they are given), declaring the resolution `ppi` when both of its values are finite and +/// above 0, and carrying `icc` as tag 34675 (ICCProfile, of type UNDEFINED); `None` when it can't be +/// written. +fn write_cmyk_tiff(width: u32, height: u32, samples: SampleSlice<'_>, ppi: Option<(f64, f64)>, icc: Option<&[u8]>) -> Option> { + use tiff::encoder::colortype::{CMYK8, CMYK16, ColorType}; + use tiff::encoder::{Compression, ImageEncoder, Rational, TiffEncoder, TiffKind, compression::DeflateLevel}; + use tiff::tags::{ResolutionUnit, Tag}; + fn tags( + img: &mut ImageEncoder<'_, W, C, K>, + ppi: Option<(f64, f64)>, + icc: Option<&[u8]>, + ) -> Option<()> { + if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; + } + if let Some(icc) = icc { + img.encoder().write_tag(Tag::IccProfile, Undefined(icc)).ok()?; + } + Some(()) + } + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + match samples { + SampleSlice::Eight(data) => { + let mut img = enc.new_image::(width, height).ok()?; + tags(&mut img, ppi, icc)?; + img.write_data(data).ok()?; + } + SampleSlice::Sixteen(data) => { + let mut img = enc.new_image::(width, height).ok()?; + tags(&mut img, ppi, icc)?; + img.write_data(data).ok()?; + } + } + Some(out.into_inner()) +} + +/// Bytes written as a TIFF field of type UNDEFINED, the type of an embedded ICC profile. +struct Undefined<'a>(&'a [u8]); + +impl tiff::encoder::TiffValue for Undefined<'_> { + const BYTE_LEN: u8 = 1; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED; + + fn count(&self) -> usize { + self.0.len() + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + std::borrow::Cow::Borrowed(self.0) + } } impl ImageBlob { @@ -195,20 +351,8 @@ impl ImageBlob { /// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per /// inch) when it is given and both are finite and above 0. pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option { - use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; - use tiff::tags::{ResolutionUnit, Tag}; - let mut out = Cursor::new(Vec::new()); - let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); - let mut img = enc.new_image::(inks.width, inks.height).ok()?; - if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { - // Ten-thousandths of a pixel per inch. - let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; - img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; - img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; - img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; - } - img.write_data(&inks.data).ok()?; - Some(Self::new("image/tiff", out.into_inner())) + let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), ppi, None)?; + Some(Self::new("image/tiff", tiff)) } /// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its @@ -333,30 +477,38 @@ fn tiff_inks(bytes: &[u8]) -> Option { /// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and /// Place store them: as they are, or, when they store one plane per ink, which the image decoder -/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution -/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +/// doesn't read, their inks together in a CMYK TIFF with the resolution `ppi`, at the 8 or 16 bits +/// the file has, carrying the file's CMYK ICC profile. `None` for other images and for CMYK TIFFs +/// whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { - let inks = tiff_inks(bytes)?; + let source = tiff_source(bytes)?; let (_, mut d) = tiff(bytes)?; if !planar(&mut d) { return Some(bytes.to_vec()); } - ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) + write_cmyk_tiff(source.width, source.height, source.samples.as_slice(), ppi, source.icc.as_deref()) } /// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their -/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by -/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned -/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`] -/// doesn't read. +/// inks turned and written again as a CMYK TIFF with the resolution `ppi` of the turned pixels, +/// with the turned width and height. A TIFF keeps its 8 or 16 bits, a JPEG has 8, and both keep +/// their CMYK ICC profile (a JPEG's put together from all its APP2 segments). `None` for other +/// images and for CMYK images whose inks [`ImageBlob::cmyk`] doesn't read. pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { - let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?; - // Four 8-bit inks to a pixel, turned as four 8-bit channels are. - let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + let CmykSource { width, height, samples, icc } = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_source(bytes) } else { tiff_source(bytes) }?; + // Four inks to a pixel, turned as four channels of the same depth are. + let mut px = match samples { + Samples::Eight(v) => image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(width, height, v)?), + Samples::Sixteen(v) => image::DynamicImage::ImageRgba16(image::ImageBuffer::from_raw(width, height, v)?), + }; px.apply_orientation(orientation); - let inks = Inks::new(px.width(), px.height(), px.into_bytes())?; - let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; - Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height))) + let size = (px.width(), px.height()); + let samples = match px { + image::DynamicImage::ImageRgba16(v) => Samples::Sixteen(v.into_raw()), + other => Samples::Eight(other.into_rgba8().into_raw()), + }; + let tiff = write_cmyk_tiff(size.0, size.1, samples.as_slice(), ppi, icc.as_deref())?; + Some((tiff, size)) } /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index cf7a4d2fa0..3cf8312289 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -39,7 +39,7 @@ fn fifty() -> f64 { // ===================================================================================== pub use crate::blend::{ - Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, + Spine, blend_expand, blend_layout_line, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, steps_knockout_shows, }; @@ -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::{ 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/pixels.rs b/crates/doc/src/pixels.rs index 0812095826..1c231f7bbe 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 { diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 14f9a0d08f..6d58ad9d74 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -29,3 +29,5 @@ serde_json = { workspace = true } vectorcraft-color = { workspace = true } # A face built in code from the bundled Source Sans 3 (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } +# Test live-effect plug-ins written in WebAssembly text. +wat = { workspace = true } 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/catalog.rs b/crates/effects/src/catalog.rs index 52232cd54e..424beebe17 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,8 +182,8 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[]}), ), g( EXTRUDE, 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 fa06d00922..ac61746266 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -95,7 +95,8 @@ pub use solids::{ validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, }; pub use solids::{ - GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, rename_appearance_graphic_symbol, + GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, GRAPHICS_NOT_TAKEN, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, + rename_appearance_graphic_symbol, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c5aaed7309..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, @@ -1901,22 +1927,30 @@ fn paint_leaf( /// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most /// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths. fn piece_cost(path: &PathData) -> Budget { + Budget::of(path).plus(piece_share()) +} + +/// The share of the effects' budget each painted piece costs besides its path ([`piece_cost`]). +fn piece_share() -> Budget { let max = vectorcraft_plugins::objects::MAX_OBJECTS; - Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max }) + Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max } } /// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of -/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference -/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that -/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item`, the leaf's first -/// fill or stroke, with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's -/// other fills and strokes; one that gives none keeps the leaf's. Returns +/// `item`, run on `leaf`, a path of [`transform_piece`] painted by `item` (its first fill or stroke), +/// with reference box `bounds`: `leaf` becomes a group of one path for each piece returned, in +/// order. The plug-in is given `appearance`'s fills and strokes and told that it is on item `index` +/// of them. A piece that gives fills (on a fill) or strokes (on a stroke) is painted by copies of +/// `item` with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's other fills +/// and strokes; one that gives none keeps the leaf's. Returns /// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, /// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass /// `budget`. A deferred run is listed by `at`. +#[allow(clippy::too_many_arguments)] fn paint_piece( leaf: &mut Node, item: &AppearanceItem, + (appearance, index): (&Appearance, usize), (plugin, params): (&str, &serde_json::Value), bounds: Rect, budget: Budget, @@ -1924,7 +1958,7 @@ fn paint_piece( at: Option, ) -> Option { let (path, rule) = leaf_geometry(leaf)?; - let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at }; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(appearance), item: Some(index), at }; let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); if !budget.fits(made) { @@ -2066,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(); @@ -2093,7 +2132,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), true, limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -2116,7 +2155,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt if moved && !held_later { follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); } - transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), false, limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -2129,7 +2168,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt leaf.kind = NodeKind::Group { children: pieces, clip: false }; } -/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a +/// Apply the visible geometry effects left on the fills or strokes of each path under `p` (a /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's /// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of @@ -2138,19 +2177,32 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// the effects after it, and with Transform Objects off those before it, as they are /// ([`keep_listed`]). /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path -/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect -/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before -/// the piece's effects that makes copies or moves at random holds back, and the effects before -/// such a Transform effect among the piece's effects. The geometry effects then +/// for each piece it returns ([`paint_piece`]). A path with several fills or strokes (those a +/// painted plug-in returned) takes such an effect, a live-effect plug-in or a geometry effect on +/// strokes once for each of them, in paint order, given its own place among them +/// ([`shares_stage`]): each run's pieces replace only that fill or stroke, and a run that fails or +/// would pass the budget leaves that fill or stroke on the path as it was. The raster effects +/// before a Transform effect follow it ([`follow_piece`]), except the first `held` effects, which +/// a Transform effect before the piece's effects that makes copies or moves at random holds back, +/// and the effects before such a Transform effect among the piece's effects. The geometry effects then /// leave the fills and strokes; their other effects stay. The paths keep to `max` together with /// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in /// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the /// fill or stroke the piece was made from, and the index among that item's effects of each effect -/// the piece kept, which a deferred live-effect plug-in is listed by. -fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// the piece kept, which a deferred live-effect plug-in is listed by. `painted`: `p`'s paths are +/// the pieces of a painted run, each of which `used` counts at [`piece_cost`]. +fn transform_piece( + p: &mut Node, + (seed, source, kept): (u64, usize, &[usize]), + held: usize, + painted: bool, + max: Budget, + used: &mut Budget, + doc: Option<&Document>, +) { let mut count = 0; for_each_leaf(p, &mut |l| { - if let Some(item) = l.appearance.items.first() { + for item in &l.appearance.items { count = count.max(item.effects().len()); } }); @@ -2158,60 +2210,207 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h // random are held back from the Transform effects before it too. let mut held = held; for_each_leaf(p, &mut |l| { - if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { - held = held.max(last); + for item in &l.appearance.items { + if let Some(last) = item.effects().iter().rposition(crate::raster::holds_back) { + held = held.max(last); + } } }); + // Whether `used` counts each path under `p`, in the order `for_each_leaf` visits them, at + // [`piece_cost`] (its geometry and a painted piece's share) rather than at its geometry alone. + // The pieces of a painted run are counted so, and so is the first path an effect makes of one + // that was: the paths of a Transform effect's copies share their original's share. + let mut shares = vec![painted; leaf_count(p)]; for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; let from = held.min(j); let mut holds = false; - for_each_leaf(p, &mut |leaf| { - let Some(item) = leaf.appearance.items.first().cloned() else { return }; - let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; - holds |= crate::raster::holds_back(&e); - // The effects before this one as they were before it moved the path. - let was = item.effects().get(from..j).unwrap_or_default(); - let Some((path, rule)) = leaf_geometry(leaf) else { return }; - // What the other paths leave of the budget. - let before = Budget::of(&path); - let room = max.minus(used.minus(before)); - let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) }; - if let Some(plugin) = painted_plugin(&e) { - if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) { - *used = used.minus(before).plus(made); - } - return; - } - if reaches_appearance(&e) { - let params = merged_params(&e.id, &e.params); - let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); - let options = crate::distort::TransformOptions::of(¶ms); - let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let keep = |k: usize| k > j || (k < j && !options.objects); - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep))); + let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) }; + let at = PieceStage { j, from, seed, bounds, doc, stage }; + // Stage `j` on `leaf`, a path `used` counts at `piece_cost` when `share`: whether `used` + // counts each path it became so, or `None` when it stays as it was. + let mut on_leaf = |leaf: &mut Node, share: bool| -> Option> { + let effect = |item: &AppearanceItem| item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned(); + let items = &leaf.appearance.items; + let first = items.first()?; + let (path, _) = leaf_geometry(leaf)?; + // What `used` counts the path at; what the other paths leave of the budget is the rest. + let geometry = Budget::of(&path); + let counted = if share { piece_cost(&path) } else { geometry }; + if shares_stage(items, j) { + let e = effect(first)?; + holds |= crate::raster::holds_back(&e); + let appearance = leaf.appearance.clone(); + // A painted run's pieces each cost a share of their own ([`paint_piece`]); the + // paths of any other effect keep the leaf's. + let painted = painted_plugin(&e).is_some(); + let before = if painted { counted } else { geometry }; + let room = max.minus(used.minus(before)); + let made = piece_stage(leaf, &e, &at, (&appearance, 0), room)?; *used = used.minus(before).plus(made); - if moved { - follow_piece(leaf, was, from, &e); - } - return; + return Some((0..leaf_count(leaf)).map(|k| painted || (share && k == 0)).collect()); } + // Each fill or stroke takes the effect on its own path, given the leaf's fills and + // strokes and its own place among them; those left with the same path as the one + // before them stay on it. Each path costs its geometry and a painted piece's share. + // Each run is first given all the room the art leaves. When a fill or stroke that keeps + // the leaf's path then finds no room for its copy of it, the results are placed again, + // each but the last leaving room for one more copy of the leaf's own path; a result + // that does not fit that room is left out. Only a fill or stroke that keeps the leaf's + // path right after one that changed costs such a copy (the ones after it stay on that + // copy), so every copy fits but the first fill's or stroke's, when the art leaves no + // room for it: the leaf then stays as it was. A live-effect plug-in is given all the + // room the art leaves, and each placing checks that its result fits; a built-in effect + // builds within the room of each placing. let appearance = leaf.appearance.clone(); - let stroke = match &item { - AppearanceItem::Stroke(s) => Some(s), - AppearanceItem::Fill(_) => None, + let one = piece_cost(&path); + let n = appearance.items.len(); + let base = used.minus(counted); + let alone = |item: &AppearanceItem| { + let mut sub = leaf.clone(); + sub.appearance.items = vec![item.clone()]; + sub }; - let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) }; - let mut carry = crate::Carry::default(); - let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry)); - *used = used.minus(before).plus(Budget::of(&out)); - // The fill's or stroke's gradient follows the effect as the object's does. - if carry.moves(&leaf.appearance) { - carry.apply(&mut leaf.appearance); - } - set_geometry(leaf, out, rule); - follow_piece(leaf, was, from, &e); + // `item`, the `k`th fill or stroke, alone on the leaf's path with effect `e` applied + // within `room`, and what its paths cost; `None` when it failed or found no room. + let apply = |item: &AppearanceItem, k: usize, e: &Effect, room: Budget| { + let painted = painted_plugin(e).is_some(); + let room = if painted { room } else { room.minus(piece_share()) }; + let mut sub = alone(item); + let made = piece_stage(&mut sub, e, &at, (&appearance, k), room)?; + Some((sub, if painted { made } else { made.plus(piece_share()) })) + }; + // Each fill's or stroke's live-effect plug-in run. A result is kept only while a + // placing places it on a path of its own; one that does not fit is dropped, with what + // it cost, and the plug-in runs again in the second placing only when that much room + // is left there (a result small enough to cache comes back from the cache). A result + // with the same path as the one before it costs nothing: it is placed on that path + // whatever room is left, and dropped once its fill or stroke is moved there. The + // results kept at once cost at most what the two placings place. + let mut runs: Vec = vec![PluginRun::Not; n]; + let mut kept_cost = Budget { anchors: 0, lines: 0 }; + // Each placing gives the paths, whether `used` counts each of the paths they hold at + // `piece_cost` (all of a painted run's, and the first of any other result's), and what + // the art then spends. + let mut split = |reserve: bool| -> Option<(Vec, Vec, Budget)> { + let mut spent = base; + let mut out: Vec = vec![]; + let mut counts: Vec = vec![]; + for (k, (item, ran)) in appearance.items.iter().zip(runs.iter_mut()).enumerate() { + let room = if reserve && k + 1 < n { max.minus(spent).minus(one) } else { max.minus(spent) }; + let e = effect(item); + let paints = e.as_ref().is_some_and(|e| painted_plugin(e).is_some()); + // Does `sub` stay on the path of the result before it, at no cost? + let merges = |sub: &Node| out.last().is_some_and(|last| same_path(last, sub)); + // What the kept plug-in result this fill or stroke takes cost. + let mut taken = None; + let result = match e { + None => None, + Some(e) => { + holds |= crate::raster::holds_back(&e); + if vectorcraft_plugins::effect::plugin_id(&e.id).is_some() { + let again = match ran { + PluginRun::Not | PluginRun::Merged => true, + PluginRun::Left(cost) => room.fits(*cost), + PluginRun::Kept(..) | PluginRun::Failed => false, + }; + if again { + #[cfg(test)] + PLUGIN_RUNS.with(|n| n.set(n.get() + 1)); + *ran = match apply(item, k, &e, max.minus(base)) { + None => PluginRun::Failed, + Some((sub, cost)) if !room.fits(cost) && !merges(&sub) => PluginRun::Left(cost), + Some((sub, cost)) => { + kept_cost = kept_cost.plus(cost); + PluginRun::Kept(sub, cost) + } + }; + } + match ran { + PluginRun::Kept(sub, cost) if room.fits(*cost) || merges(sub) => { + taken = Some(*cost); + Some((sub.clone(), *cost)) + } + PluginRun::Kept(_, cost) => { + let cost = *cost; + kept_cost = kept_cost.minus(cost); + *ran = PluginRun::Left(cost); + None + } + PluginRun::Not | PluginRun::Merged | PluginRun::Left(_) | PluginRun::Failed => None, + } + } else { + // A built-in effect that finds no room within `room` leaves the + // leaf's path. When its result is not on the path before it, it + // is built again with all the room the art leaves and taken only + // when that result is on the path before it, which costs nothing. + let made = apply(item, k, &e, room); + let short = match &made { + None => true, + Some((sub, cost)) => !merges(sub) && (!room.fits(*cost) || same_path(sub, leaf)), + }; + match out.last() { + Some(_) if short => match apply(item, k, &e, max.minus(base)) { + Some((sub, cost)) if merges(&sub) => Some((sub, cost)), + _ => made, + }, + _ => made, + } + } + } + }; + let placed = match result { + Some((sub, cost)) if room.fits(cost) || merges(&sub) => Some((sub, cost, paints)), + _ => None, + }; + // A fill or stroke whose result is not placed stays on the leaf's path, which + // is copied only when the path before it is another. + let (mut sub, cost, all) = match placed { + Some(placed) => placed, + None => match out.last_mut() { + Some(last) if same_path(last, leaf) => { + last.appearance.items.push(item.clone()); + continue; + } + _ => (alone(item), one, true), + }, + }; + match out.last_mut() { + Some(last) if same_path(last, &sub) => { + last.appearance.items.append(&mut sub.appearance.items); + if let Some(cost) = taken { + kept_cost = kept_cost.minus(cost); + *ran = PluginRun::Merged; + } + } + _ if !max.minus(spent).fits(cost) => return None, + _ => { + spent = spent.plus(cost); + counts.extend((0..leaf_count(&sub)).map(|k| all || k == 0)); + out.push(sub); + } + } + #[cfg(test)] + HELD_PEAK.with(|p| p.set(p.get().at_least(kept_cost))); + } + Some((out, counts, spent)) + }; + let (mut out, counts, spent) = split(false).or_else(|| split(true))?; + *used = spent; + *leaf = match out.len() { + 1 => out.pop().unwrap_or_else(|| leaf.clone()), + _ => Node::group(leaf.id, out.into_iter().map(Arc::new).collect()), + }; + Some(counts) + }; + let mut next: Vec = vec![]; + let mut visited = 0; + for_each_leaf(p, &mut |leaf| { + let share = shares.get(visited).copied().unwrap_or(false); + visited += 1; + next.extend(on_leaf(leaf, share).unwrap_or_else(|| vec![share])); }); + shares = next; if holds { held = held.max(j); } @@ -2223,6 +2422,137 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h }); } +/// A fill's or stroke's live-effect plug-in run in a placing of [`transform_piece`]: not run yet, +/// its result and what that costs while a placing places it on a path of its own, what a result +/// that did not fit cost (the result itself is dropped), a result dropped once its fill or stroke +/// was placed on the path before it, or failed. +#[derive(Clone)] +enum PluginRun { + Not, + Kept(Node, Budget), + Left(Budget), + Merged, + Failed, +} + +#[cfg(test)] +thread_local! { + /// The most the live-effect plug-in results [`transform_piece`] keeps from one fill or stroke + /// to the next have cost at once on this thread. + pub(crate) static HELD_PEAK: std::cell::Cell = const { std::cell::Cell::new(Budget { anchors: 0, lines: 0 }) }; + /// How many times [`transform_piece`] has run a fill's or stroke's live-effect plug-in on this + /// thread when they did not share the stage. + pub(crate) static PLUGIN_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Where a stage of [`transform_piece`] runs: `j`, the index of its effect among the piece's +/// effects; `from`, the first of the effects before it whose raster effects follow a Transform +/// effect; the object's `seed`; the reference box `bounds` of all the piece's paths; the document +/// that painted pieces' swatches and patterns must be in; and the `stage` a deferred live-effect +/// plug-in is listed by. +struct PieceStage<'a> { + j: usize, + from: usize, + seed: u64, + bounds: Rect, + doc: Option<&'a Document>, + stage: vectorcraft_plugins::deferred::Stage, +} + +/// Can stage `j` of [`transform_piece`] run once for all of a path's `items`: there is at most one, +/// or they hold the same effects and the effect at `j` is not a visible geometry effect, is a +/// Transform effect, or is a built-in geometry effect and they are all fills (an effect that +/// changes a fill's path does not depend on which fill it is on)? +fn shares_stage(items: &[AppearanceItem], j: usize) -> bool { + let Some(first) = items.first() else { return true }; + if items.len() == 1 { + return true; + } + if !items.iter().all(|i| i.effects() == first.effects()) { + return false; + } + match first.effects().get(j) { + Some(e) if e.visible && is_geometry(&e.id) => { + vectorcraft_plugins::effect::plugin_id(&e.id).is_none() && (reaches_appearance(e) || items.iter().all(AppearanceItem::is_fill)) + } + _ => true, + } +} + +/// Effect `e`, a visible geometry effect at stage `at` of [`transform_piece`], applied to `leaf`, a +/// path painted by its first fill or stroke (and, when they share the stage, [`shares_stage`], the +/// others): `appearance` is what the effect is given of the path's fills and strokes and `index` +/// which of them it is on. A live-effect plug-in that paints what it returns makes `leaf` a group +/// of its pieces ([`paint_piece`]), and a Transform effect that reaches its appearance transforms +/// it ([`transform_leaf`]). Returns what the new paths cost, within `room`; `None` when `leaf` +/// stays as it is. +fn piece_stage(leaf: &mut Node, e: &Effect, at: &PieceStage, (appearance, index): (&Appearance, usize), room: Budget) -> Option { + let item = leaf.appearance.items.first()?.clone(); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(at.from..at.j).unwrap_or_default(); + let (path, rule) = leaf_geometry(leaf)?; + if let Some(plugin) = painted_plugin(e) { + let params = merged_params(&e.id, &e.params); + return paint_piece(leaf, &item, (appearance, index), (plugin, ¶ms), at.bounds, room, at.doc, Some(at.stage)); + } + if reaches_appearance(e) { + let params = merged_params(&e.id, &e.params); + let (_, copies) = crate::distort::transform_map(at.bounds, ¶ms, at.seed); + let options = crate::distort::TransformOptions::of(¶ms); + let map = crate::distort::transform_affine(at.bounds, ¶ms, at.seed); + let j = at.j; + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep))); + if moved { + follow_piece(leaf, was, at.from, e); + } + return Some(made); + } + let stroke = match &item { + AppearanceItem::Stroke(s) => Some(s), + AppearanceItem::Fill(_) => None, + }; + let ctx = GeomContext { stroke, rule, seed: at.seed, appearance: Some(appearance), item: Some(index), doc: at.doc, stage: Some(at.stage) }; + let mut carry = crate::Carry::default(); + let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(e), &path, at.bounds, &ctx, room, Some(&mut carry)); + let made = Budget::of(&out); + // The fill's or stroke's gradient follows the effect as the object's does. + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } + set_geometry(leaf, out, rule); + // 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) +} + +/// Are `a` and `b` paths or compound paths that differ only in their fills and strokes? +fn same_path(a: &Node, b: &Node) -> bool { + let leaf = |n: &Node| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + if !leaf(a) || !leaf(b) { + return false; + } + // Every field but the fills and strokes, compared in place; the geometry last. + let Node { id, name, visible, locked, opacity, blend, isolate, knockout, knockout_shape, appearance, kind, bbox_angle, extras } = a; + let Appearance { items: _, effects, contents_index } = &**appearance; + *id == b.id + && *name == b.name + && *visible == b.visible + && *locked == b.locked + && *opacity == b.opacity + && *blend == b.blend + && *isolate == b.isolate + && *knockout == b.knockout + && *knockout_shape == b.knockout_shape + && *bbox_angle == b.bbox_angle + && *extras == b.extras + && *effects == b.appearance.effects + && *contents_index == b.appearance.contents_index + && *kind == b.kind +} + /// The effects a Transform effect in a fill's or stroke's own list leaves as they were /// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its /// indices keep their values. `None` for an object's own Transform effect. diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 1677937f17..9281a802bf 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -29,6 +29,15 @@ pub fn is_revolve(id: &str) -> bool { /// thousands of faces took gigabytes; past this the source path is drawn. pub(crate) const MAX_PAINT_COPIES: usize = 256 << 20; +/// The bytes of an object's paint copies so far: those of its surfaces' faces, and those of the parts +/// of its graphics, each within [`MAX_PAINT_COPIES`] of its own. Graphics past their budget are left +/// out without taking room the surfaces need. +#[derive(Default)] +struct Copies { + surfaces: usize, + graphics: usize, +} + /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22; @@ -96,13 +105,18 @@ pub fn revolve_options(p: &Value) -> Revolve { smooth_shading: true, cap_ends: flag(p, "cap", true), curve_samples: None, + map_surface: false, } } /// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with -/// parameters `p` sweeps its surface with. +/// parameters `p` sweeps its surface with. A Revolve with graphics lays out its side for them. fn options_of(id: &str, p: &Value) -> Revolve { - if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) } + if id == CLASSIC_REVOLVE { + classic::options(p) + } else { + Revolve { map_surface: id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty(), ..revolve_options(p) } + } } /// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with. @@ -140,6 +154,17 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; + // Without graphics, or without the document they draw from, no member is measured for them. + if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) { + match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) { + Some(s) => check_graphics(Some(doc), id, &s.path, b, options, p, &mut 0)?, + // With no member to carry them, the graphics' art must still fit its limits. + None => { + crate::solids::graphics::validate_symbols(doc, p)?; + crate::solids::graphics::validate_surface_symbols(doc, p)?; + } + } + } let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); return check_shadow(p, casting.map(|s| &s.path), b, options); } @@ -151,9 +176,44 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?; check_shadow(p, [&path], b, options_of(id, p)) } +/// An error when the graphics in Revolve (`id`) parameters `p`, with the symbols of `doc`, are past +/// their limits on the surface of `path` about the axis of `b`: their art, or the parts they draw +/// on its faces ([`crate::solids::graphics::surface_parts`]), whose paint copies count after +/// `copies`, the bytes of the copies of the object's graphics so far ([`Copies`]). The parts' +/// copies are added to `copies` when they fit, as drawing adds them. Without `doc` nothing is +/// drawn, and nothing is checked. +fn check_graphics(doc: Option<&Document>, id: &str, path: &PathData, b: Rect, options: Revolve, p: &Value, copies: &mut usize) -> Result<(), String> { + let graphics = crate::solids::mapped_graphics(p); + let Some(doc) = doc.filter(|_| id == REVOLVE && !graphics.is_empty()) else { return Ok(()) }; + crate::solids::graphics::validate_symbols(doc, p)?; + crate::solids::graphics::validate_surface_symbols(doc, p)?; + let faces = vectorcraft_three_d::revolve(path, b, options)?; + let mut used = *copies; + match crate::solids::graphics::surface_parts(Some(doc), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), &mut used) { + Some(_) => { + *copies = used; + Ok(()) + } + None => Err("The graphics are too complex for this surface: simplify the symbols' art or reduce Segments".into()), + } +} + +/// The member of a group whose side takes the group's graphics: the first with the longest +/// profile among those with a fill or stroke. +fn graphics_member(shapes: &[Profile], b: Rect, options: Revolve) -> Option { + shapes + .iter() + .enumerate() + .filter(|(_, s)| surface_item(&s.items).is_some()) + .filter_map(|(k, s)| vectorcraft_three_d::revolve_surface_size(&s.path, b, options).map(|(_, h)| (k, h))) + .fold(None, |best: Option<(usize, f64)>, (k, h)| if best.is_some_and(|(_, top)| top >= h) { best } else { Some((k, h)) }) + .map(|(k, _)| k) +} + /// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast /// from, about the axis of `b`, are past the limits together. fn check_shadow<'a>(p: &Value, paths: impl IntoIterator, b: Rect, options: Revolve) -> Result<(), String> { @@ -168,16 +228,17 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator, b: /// appearance item `target` of `n` ([`validate_item_of`]). #[cfg(test)] pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { - validate_item_of(n, REVOLVE, p, target, index) + validate_item_of(None, n, REVOLVE, p, target, index) } /// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index /// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke /// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible -/// object-level Revolve draws in place of those surfaces. -pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> { +/// object-level Revolve draws in place of those surfaces. With `doc`, `target`'s graphics must fit +/// its surface ([`check_graphics`]). +pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> { if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { - return validate_in(None, n, id, p); + return validate_in(doc, n, id, p); } validate_params_of(id, p)?; const EMPTY: &str = "Revolve requires a shape with at least two anchors"; @@ -187,7 +248,7 @@ pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, ind } let ctx = GeomContext::of(n); let path = geometry(&path, &n.appearance.effects, &ctx); - let mut copies = 0; + let (mut copies, mut graphic_copies) = (0, 0); for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) }; @@ -200,6 +261,12 @@ pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, ind let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + // The graphics of every item add their paints' copies to the graphics' budget, as they + // are drawn; only the edited item's graphics are refused here. + let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies); + if i == target { + graphics?; + } check_shadow(¶ms, [&path], b, options)?; } Ok(()) @@ -234,7 +301,7 @@ fn shared_options(shapes: &[Profile], options: Revolve) -> Result Option<&AppearanceItem> { +pub(crate) fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> { items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last()) } @@ -255,6 +322,7 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { /// only its own parameters, each in its range. pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; + crate::solids::graphics::validate(id, p)?; if id == CLASSIC_REVOLVE { classic::validate(p)?; } @@ -303,10 +371,13 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R } /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts -/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face -/// with its classic surface, as Extrude & Bevel (Classic) does. +/// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face +/// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the +/// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): +/// a face with a part of a graphic is a group of the face and that part. #[allow(clippy::too_many_arguments)] fn surface( + doc: Option<&Document>, n: &Node, path: &PathData, b: Rect, @@ -316,14 +387,14 @@ fn surface( opacity: f32, blend: vectorcraft_doc::color::BlendMode, expand: bool, - copies: &mut usize, + copies: &mut Copies, ) -> Option<(Vec, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; - *copies = copies.saturating_add(paint_copies(facets.len(), &paint)); - if *copies > MAX_PAINT_COPIES { + copies.surfaces = copies.surfaces.saturating_add(paint_copies(facets.len(), &paint)); + if copies.surfaces > MAX_PAINT_COPIES { return None; } let depths = facets.iter().map(|f| f.depth).collect(); @@ -332,14 +403,33 @@ fn surface( } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; - *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); - if *copies > MAX_PAINT_COPIES { + copies.surfaces = copies.surfaces.saturating_add(paint_copies(faces.len(), &paint)); + if copies.surfaces > MAX_PAINT_COPIES { return None; } + let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)); + let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored); + // Graphics past the limits are left out, and the surface draws without them and their paints' + // copies. + let mut used = copies.graphics; + let mut parts = if graphics.is_empty() { + vec![] + } else { + match crate::solids::graphics::surface_parts(doc, n.id, &graphics, &faces, &shade, &mut used) { + Some(parts) => { + copies.graphics = used; + parts + } + None => vec![], + } + }; + let drawn = parts.iter().any(|p| !p.is_empty()); // Visibility is an expansion option, never a change to live geometry or export baking. // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility - // failure also retains the complete surface rather than losing any of the user's art. + // failure also retains the complete surface rather than losing any of the user's art. With + // graphics drawn, every face stays, each with its part of the graphics. let visible = (expand + && !drawn && flag(&fx.params, "expandVisibleOnly", true) && paint.color().is_some() && opacity == 1.0 @@ -414,7 +504,16 @@ fn surface( .collect(); Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() })) }) - .collect(); + .collect::>(); + let children = if drawn { + children + .into_iter() + .zip(parts.iter_mut().map(std::mem::take).chain(std::iter::repeat_with(Vec::new))) + .map(|(face, part)| if part.is_empty() { face } else { Arc::new(Node::group(n.id, std::iter::once(face).chain(part).collect())) }) + .collect() + } else { + children + }; let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; @@ -422,11 +521,33 @@ fn surface( } /// A surface of one object about the bounds of `path`, built with options derived from `fx`. -/// `copies` is the count [`surface`] adds to. -fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option { +/// `copies` is the count [`surface`] adds to; its graphics take the symbols of `doc`. +fn object_surface( + doc: Option<&Document>, + n: &Node, + path: &PathData, + fx: &Effect, + item: &AppearanceItem, + expand: bool, + copies: &mut Copies, +) -> Option { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); - surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) + surface(doc, n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) +} + +/// The paint of a graphic's solid color `c` on `face`, shaded as the face shades a solid fill of +/// that color: across the face with its smooth shading, under colored lights (`colored`) with +/// their color. +fn graphic_paint(face: &vectorcraft_three_d::Face, c: Color, colored: bool) -> Paint { + if colored { + return face + .light_gradient + .map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c)); + } + let [r, g, b] = c.to_rgb(); + let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32); + face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0)) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -592,12 +713,12 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } let n = &*resolve_for(doc, n, "Revolve", false).ok()?; if is_container(n) { - return group_art(n, expand); + return group_art(doc, n, expand); } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); let mut children = vec![]; - let mut copies = 0; + let mut copies = Copies::default(); let mut remaining = n.appearance.effects.clone(); if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = n.appearance.effects.get(index)?; @@ -606,7 +727,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let surface = object_surface(n, &path, fx, item, expand, &mut copies)?; + let surface = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; if let Some(b) = path.bounds() && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()? { @@ -632,7 +753,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; + let mut art = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()? @@ -774,7 +895,7 @@ pub(crate) fn overlaps(a: Rect, b: Rect) -> bool { /// [`art`] of group or layer `n`: every member swept about one axis on the bounds of all of them, /// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. -fn group_art(n: &Node, expand: bool) -> Option { +fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?; let fx = n.appearance.effects.get(index)?; let mut shapes = members(n, "").ok()?; @@ -782,17 +903,22 @@ fn group_art(n: &Node, expand: bool) -> Option { let b = union_bounds(&shapes)?; let options = shared_options(&shapes, fx_options(fx)).ok()?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut copies = 0; + let mut copies = Copies::default(); let mut surfaces = vec![]; let mut opaque = true; - for s in &shapes { + // The graphics go once, on the side of the member with the longest profile. Without graphics + // no member is measured for them. + let mapped = fx.id == REVOLVE && !crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)).is_empty(); + let longest = if mapped { graphics_member(&shapes, b, options) } else { None }; + for (k, s) in shapes.iter().enumerate() { let Some(item) = surface_item(&s.items) else { continue }; // The opacity and blending of the member and the groups it is in apply through its scopes. let (paint, opacity, blend) = paint_of(item); opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty(); // A member with transparency of its own keeps every face, as a transparent object does. let expand = expand && s.scopes.is_empty(); - let (depths, art) = surface(n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; + let doc = doc.filter(|_| longest == Some(k)); + let (depths, art) = surface(doc, n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; surfaces.push((depths, art, s.scopes.as_slice())); } if surfaces.is_empty() { @@ -1517,3 +1643,7 @@ mod tests { assert_eq!(expand_revolve_art(&sample), revolve_art(&sample)); } } + +#[cfg(test)] +#[path = "revolve/tests_graphics.rs"] +mod tests_graphics; diff --git a/crates/effects/src/revolve/tests_graphics.rs b/crates/effects/src/revolve/tests_graphics.rs new file mode 100644 index 0000000000..5d9500628b --- /dev/null +++ b/crates/effects/src/revolve/tests_graphics.rs @@ -0,0 +1,576 @@ +use serde_json::json; +use vectorcraft_doc::Symbol; +use vectorcraft_doc::color::Color; +use vectorcraft_geom::{BezPath, shapes}; + +use super::*; +use crate::solids::three_d_params_as; + +fn bar(r: Rect, color: Color) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(color), Paint::None, 0.0)) +} + +/// A document with symbols "Red" and "Blue" (10 × 20 bars) and "Wide" (a red 300 × 10 bar). +fn document() -> Document { + let mut d = Document::new(1000.0, 1000.0); + for (name, r, color) in [ + ("Red", Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(1.0, 0.0, 0.0)), + ("Blue", Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(0.0, 0.0, 1.0)), + ("Wide", Rect::new(0.0, 0.0, 300.0, 10.0), Color::rgb(1.0, 0.0, 0.0)), + ("Flat", Rect::new(0.0, 0.0, 10.0, 2.0), Color::rgb(1.0, 0.0, 0.0)), + ] { + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(bar(r, color)) }); + } + d +} + +/// A gray open profile through `points` (its first and last on the axis at x 0), revolved with +/// `params` over a view straight on without shading. +fn revolved(points: &[(f64, f64)], params: Value) -> Node { + let mut b = BezPath::new(); + b.move_to(points[0]); + points[1..].iter().for_each(|p| b.line_to(*p)); + let mut p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false}); + p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone()); + let mut n = Node::path(NodeId(7), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + n +} + +/// A cylinder of radius 50 about x 0, from y 0 to 200. +const CYLINDER: [(f64, f64); 4] = [(0.0, 0.0), (50.0, 0.0), (50.0, 200.0), (0.0, 200.0)]; + +/// A profile that narrows from 50 to 20 from the axis at y 100 and widens again. +const NECK: [(f64, f64); 5] = [(0.0, 0.0), (50.0, 0.0), (20.0, 100.0), (50.0, 200.0), (0.0, 200.0)]; + +/// The red channel of each color `paint` draws when it is a shade of pure red or pure blue (`blue`): +/// a solid color or the stops of a gradient. `None` for any other paint. +fn shade_of(paint: &Paint, blue: bool) -> Option> { + let hue = |c: &Color| { + let [r, g, b] = c.to_rgb(); + let (on, off) = if blue { (b, r) } else { (r, b) }; + (on > 0.02 && off < 0.01 && g < 0.01).then_some(on) + }; + match paint { + Paint::Solid { .. } => Some(vec![hue(&paint.color()?)?]), + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| hue(&s.color)).collect(), + _ => None, + } +} + +/// The paths in `art` filled in a shade of red (or blue), with their shades. +fn parts(art: &Node, blue: bool) -> Vec<(PathData, Vec)> { + let mut out = vec![]; + art.walk(&mut |n| { + let Some(path) = n.path_data() else { return }; + let fill = n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => shade_of(&f.paint, blue), + AppearanceItem::Stroke(_) => None, + }); + if let Some(shades) = fill { + out.push((path.clone(), shades)); + } + }); + out +} + +fn reach(parts: &[(PathData, Vec)]) -> Rect { + parts.iter().filter_map(|(p, _)| p.bounds()).reduce(|a, b| a.union(b)).unwrap() +} + +fn near(a: f64, b: f64, tolerance: f64) -> bool { + (a - b).abs() <= tolerance +} + +#[test] +fn a_graphic_wraps_around_the_side_and_narrows_toward_the_silhouette() { + let d = document(); + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}, {"symbol": "Blue", "x": 60.0}]})); + let art = revolve_art_in(Some(&d), &n).unwrap(); + // At the middle of the side, facing the viewer, the bar keeps its size: 10 wide at the axis's + // line, from 10 above the middle of the profile to 10 below. + let front = reach(&parts(&art, false)); + let half = 50.0 * (5.0f64 / 50.0).sin(); + assert!(near(front.x0, -half, 0.05) && near(front.x1, half, 0.05), "{front:?}"); + assert!(near(front.y0, 90.0, 1e-6) && near(front.y1, 110.0, 1e-6), "{front:?}"); + // 60 to its right the side has turned 1.1 to 1.3 radians away: the same bar draws less than + // half as wide, at the sine of those angles. + let turned = reach(&parts(&art, true)); + assert!(near(turned.x0, 50.0 * 1.1f64.sin(), 0.15) && near(turned.x1, 50.0 * 1.3f64.sin(), 0.15), "{turned:?}"); + assert!(turned.width() < front.width() / 2.0); +} + +#[test] +fn the_part_past_the_silhouette_and_on_the_back_is_not_drawn() { + let d = document(); + // A bar wider than the half of the side seen from the front reaches the silhouette and no + // further. + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Wide"}]})); + let wide = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + assert!(wide.x0 > -50.0 - 1e-6 && wide.x1 < 50.0 + 1e-6, "{wide:?}"); + assert!(wide.x0 < -49.5 && wide.x1 > 49.5, "{wide:?}"); + // On the back, half a turn around the axis, nothing is drawn. + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red", "x": 150.0}]})); + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); + // Turned half a turn, the back faces the viewer. + let n = revolved(&CYLINDER, json!({"rotationY": 180.0, "graphics": [{"symbol": "Red", "x": 150.0}]})); + assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +#[test] +fn a_graphic_follows_the_profile_and_keeps_its_size_where_it_comes_nearer_the_axis() { + let d = document(); + // A 10 wide bar keeps its length around the axis at every distance from it: at the neck, 20 + // from the axis, it spans 0.5 radians; half way up the slanted side (52.2 along it, 35 from + // the axis, at y 50), 2/7 of a radian. Straight on, both draw nearly 10 wide. + let slant = (30.0f64 * 30.0 + 100.0 * 100.0).sqrt(); + let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat"}, {"symbol": "Flat", "y": -slant / 2.0}]})); + let art = revolve_art_in(Some(&d), &n).unwrap(); + let all = parts(&art, false); + let neck: Vec<_> = all.iter().filter(|(p, _)| p.bounds().unwrap().center().y > 75.0).cloned().collect(); + let up: Vec<_> = all.iter().filter(|(p, _)| p.bounds().unwrap().center().y <= 75.0).cloned().collect(); + let (neck, up) = (reach(&neck), reach(&up)); + // Each bar is 2 tall, so its ends are up to 0.3 farther from the axis than its middle. + assert!(near(neck.width(), 2.0 * 20.0 * 0.25f64.sin(), 0.15), "{neck:?}"); + assert!(near(neck.center().y, 100.0, 0.05), "{neck:?}"); + assert!(near(up.width(), 2.0 * 35.0 * (5.0f64 / 35.0).sin(), 0.15), "{up:?}"); + assert!(near(up.center().y, 50.0, 0.05), "{up:?}"); + // At the neck a bar 25 to the right lies 1 to 1.5 radians around, near the silhouette; one 40 + // to the right lies 1.75 to 2.25 radians around, past the silhouette, and is not drawn. + let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat", "x": 25.0}]})); + let side = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + assert!(near(side.x0, 20.0 * 1.0f64.sin(), 0.3) && near(side.x1, 20.0 * 1.5f64.sin(), 0.3), "{side:?}"); + let n = revolved(&NECK, json!({"graphics": [{"symbol": "Flat", "x": 40.0}]})); + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +#[test] +fn with_the_axis_on_the_right_a_graphic_starts_on_the_front() { + let d = document(); + // The cylinder of radius 50 about x 0, swept from its right edge. + let profile = [(0.0, 0.0), (-50.0, 0.0), (-50.0, 200.0), (0.0, 200.0)]; + let n = revolved(&profile, json!({"edge": "right", "graphics": [{"symbol": "Red"}]})); + let front = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + let half = 50.0 * (5.0f64 / 50.0).sin(); + assert!(near(front.x0, -half, 0.05) && near(front.x1, half, 0.05), "{front:?}"); + assert!(near(front.y0, 90.0, 1e-6) && near(front.y1, 110.0, 1e-6), "{front:?}"); + // x increases to the viewer's right here too. + let n = revolved(&profile, json!({"edge": "right", "graphics": [{"symbol": "Red", "x": 25.0}]})); + let right = reach(&parts(&revolve_art_in(Some(&d), &n).unwrap(), false)); + assert!(near(right.center().x, 50.0 * 0.5f64.sin(), 0.3), "{right:?}"); +} + +#[test] +fn a_graphic_is_shaded_with_the_surface() { + let d = document(); + // The light is on the left: the bar left of the middle is lighter than the one on its right, + // and neither keeps its unlit red. + let n = revolved(&CYLINDER, json!({"shade": true, "graphics": [{"symbol": "Red", "x": -30.0}, {"symbol": "Red", "x": 30.0}]})); + let all = parts(&revolve_art_in(Some(&d), &n).unwrap(), false); + let mean = |left: bool| { + let shades: Vec = + all.iter().filter(|(p, _)| (p.bounds().unwrap().center().x < 0.0) == left).flat_map(|(_, s)| s.iter().copied()).collect(); + shades.iter().sum::() / shades.len() as f32 + }; + assert!(mean(true) > mean(false) + 0.05, "{} {}", mean(true), mean(false)); + assert!(all.iter().all(|(_, s)| s.iter().all(|r| *r < 0.999))); +} + +#[test] +fn overlapping_graphics_keep_their_order_on_every_face() { + let d = document(); + // The wide red bar, listed second, reaches across the narrow blue one, listed first. Shaded, + // each face is one triangle. + let n = revolved(&CYLINDER, json!({"shade": true, "graphics": [{"symbol": "Blue", "x": 30.0}, {"symbol": "Wide"}]})); + let art = revolve_art_in(Some(&d), &n).unwrap(); + let mut both = 0; + art.walk(&mut |g| { + // A face with parts of graphics: the face's path, then the parts' paths. + let NodeKind::Group { children, clip: false } = &g.kind else { return }; + if !children.iter().all(|c| matches!(c.kind, NodeKind::Path { .. })) { + return; + } + let colors: Vec = children + .iter() + .filter_map(|c| { + let blue = !parts(c, true).is_empty(); + (blue || !parts(c, false).is_empty()).then_some(blue) + }) + .collect(); + if colors.contains(&true) && colors.contains(&false) { + both += 1; + assert!(colors.iter().position(|b| !b) > colors.iter().position(|b| *b), "{colors:?}"); + } + }); + assert!(both > 0); +} + +#[test] +fn a_graphic_past_the_end_reaches_the_curved_rim_and_no_further() { + let d = document(); + // A tube 50 from the axis with a cap at each end, in 10 segments, tilted to show its top: the + // bottom rim is an arc on the page whose lowest point, in front, lies between two corners of + // the faces. The wide bar runs 5 past the bottom of the profile. + for shade in [true, false] { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((0.0, 200.0)); + let p = json!({"offset": 50.0, "segments": 10, "rotationX": -30.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": shade, "graphics": [{"symbol": "Wide", "y": 100.0}]}); + let mut n = Node::path(NodeId(7), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap()); + let art = revolve_art_in(Some(&d), &n).unwrap(); + let mut lowest = f64::NEG_INFINITY; + art.walk(&mut |c| { + if let Some(b) = c.path_data().and_then(PathData::bounds) { + lowest = lowest.max(b.y1); + } + }); + let bar = reach(&parts(&art, false)); + assert!(near(bar.y1, lowest, 0.05), "shade {shade}: {bar:?} {lowest}"); + // A thin bar wholly past the end, 0.35 to 0.85 below it in front, lies between the faces' + // straight lower edge and the rim's arc there: it is drawn, inside the rim. + let mut thin = n.clone(); + let p = json!({"offset": 50.0, "segments": 10, "rotationX": -30.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": shade, "graphics": [{"symbol": "Flat", "y": 100.6, "scale": 25.0}]}); + thin.appearance.effects = vec![crate::new_effect(REVOLVE, &p).unwrap()]; + let past = parts(&revolve_art_in(Some(&d), &thin).unwrap(), false); + assert!(!past.is_empty(), "shade {shade}"); + assert!(reach(&past).y1 <= lowest + 1e-6, "shade {shade}: {:?} {lowest}", reach(&past)); + } +} + +#[test] +fn under_several_lights_a_graphic_takes_each_faces_own_lit_art() { + let d = document(); + let lights = json!([{"color": "#ff8080", "rotation": 120.0}, {"color": "#8080ff", "rotation": -30.0}]); + let clips = |n: &Node| { + let mut count = 0; + revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(matches!(c.kind, NodeKind::Group { clip: true, .. }))); + count + }; + let plain = revolved(&CYLINDER, json!({"shade": true, "lights": lights})); + let mapped = revolved(&CYLINDER, json!({"shade": true, "lights": lights, "graphics": [{"symbol": "Red"}]})); + // Each part of the bar is the face's lit art clipped to the part: one clip more for the + // part, one for the face's own art inside it. + assert!(clips(&mapped) > clips(&plain), "{} {}", clips(&mapped), clips(&plain)); + // A lit face draws its paint under the face's light: a layer of the light's three channels, + // multiplied over the paint. Every part of the bar takes such a layer of its own, so the bar + // is shaded with each face's colored light; a bar drawn in flat red would add none. + let lit = |n: &Node| { + let mut count = 0; + revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(c.blend == vectorcraft_doc::color::BlendMode::Multiply)); + count + }; + assert!(lit(&mapped) > lit(&plain), "{} {}", lit(&mapped), lit(&plain)); + // An opaque part also draws its lit art clipped to the band of its seam stroke; a translucent + // part, whose overlap would show, does not. + let mut d = d.clone(); + let mut clear = bar(Rect::new(0.0, 0.0, 10.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + if let Some(AppearanceItem::Fill(f)) = clear.appearance.items.first_mut() { + f.opacity = 0.5; + } + d.symbols.push(Symbol { dynamic: false, name: "Clear".into(), art: Arc::new(clear) }); + let clips = |n: &Node| { + let mut count = 0; + revolve_art_in(Some(&d), n).unwrap().walk(&mut |c| count += usize::from(matches!(c.kind, NodeKind::Group { clip: true, .. }))); + count + }; + let translucent = revolved(&CYLINDER, json!({"shade": true, "lights": lights, "graphics": [{"symbol": "Clear"}]})); + let (base, opaque, translucent) = (clips(&plain), clips(&mapped), clips(&translucent)); + assert!(translucent > base && opaque - base == 2 * (translucent - base), "{base} {opaque} {translucent}"); +} + +#[test] +fn graphics_past_the_point_limit_are_refused() { + let mut d = document(); + let dots: Vec> = (0..2000) + .map(|i| { + let c = Point::new(f64::from(i % 50) * 12.0, f64::from(i / 50) * 12.0); + Arc::new(Node::path( + NodeId(0), + shapes::ellipse(Rect::new(c.x, c.y, c.x + 10.0, c.y + 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }) + .collect(); + d.symbols.push(Symbol { dynamic: false, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), dots)) }); + let n = revolved(&CYLINDER, json!({})); + let p = |scale: f64| json!({"graphics": [{"symbol": "Dots", "scale": scale}]}); + assert!(validate_revolve_in(Some(&d), &n, &p(100.0)).is_ok()); + // At 100 times the size each dot is cut into hundreds of straight pieces. + let e = validate_revolve_in(Some(&d), &n, &p(10_000.0)).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); +} + +#[test] +fn graphics_cut_into_too_many_parts_are_refused_on_the_object_and_on_a_fill() { + let mut d = document(); + // 4,000 thin bars across the side: few points each, but each is cut at every face it crosses. + let bars: Vec> = + (0..4000).map(|i| Arc::new(bar(Rect::new(0.0, f64::from(i) * 0.05, 300.0, f64::from(i) * 0.05 + 0.02), Color::BLACK))).collect(); + d.symbols.push(Symbol { dynamic: false, name: "Bars".into(), art: Arc::new(Node::group(NodeId(0), bars)) }); + let p = json!({"segments": 128, "graphics": [{"symbol": "Bars"}]}); + let n = revolved(&CYLINDER, json!({})); + let e = validate_revolve_in(Some(&d), &n, &p).unwrap_err(); + assert!(e.contains("too complex for this surface"), "{e}"); + assert!(validate_revolve_in(Some(&d), &n, &json!({"graphics": [{"symbol": "Red"}]})).is_ok()); + // The same on a Revolve on the object's fill. + let mut filled = n.clone(); + let fx = filled.appearance.effects.remove(0); + if let Some(AppearanceItem::Fill(f)) = filled.appearance.items.first_mut() { + f.effects.push(fx); + } + let e = crate::solids::validate_three_d_at_in(Some(&d), &filled, REVOLVE, &p, (Some(0), 0)).unwrap_err(); + assert!(e.contains("too complex for this surface"), "{e}"); +} + +#[test] +fn without_the_document_nothing_is_drawn_and_expand_keeps_the_graphics() { + let d = document(); + let n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}]})); + assert!(parts(&revolve_art(&n).unwrap(), false).is_empty()); + let shown = parts(&revolve_art_in(Some(&d), &n).unwrap(), false); + let expanded = parts(&expand_revolve_art_in(Some(&d), &n).unwrap(), false); + assert!(!shown.is_empty()); + assert_eq!(shown, expanded); + // In a group the graphics go once, on the side of the member with the longest profile. + let mut member = n.clone(); + member.appearance.effects.clear(); + let mut short = member.clone(); + if let NodeKind::Path { path, .. } = &mut short.kind { + *path = PathData::from_bezpath(&{ + let mut b = BezPath::new(); + b.move_to((0.0, 80.0)); + b.line_to((30.0, 80.0)); + b.line_to((30.0, 120.0)); + b.line_to((0.0, 120.0)); + b + }); + } + let mut group = Node::group(NodeId(9), vec![Arc::new(short), Arc::new(member)]); + group.appearance.effects = n.appearance.effects.clone(); + assert_eq!(parts(&revolve_art_in(Some(&d), &group).unwrap(), false).len(), shown.len()); +} + +#[test] +fn revolve_takes_graphics_on_its_side_only() { + let front = json!({"graphics": [{"symbol": "Red"}]}); + let back = json!({"graphics": [{"symbol": "Red", "face": "back"}]}); + assert!(crate::solids::graphics::validate(REVOLVE, &front).is_ok()); + assert!(crate::solids::graphics::validate(REVOLVE, &back).unwrap_err().contains("side")); + assert!(crate::solids::graphics::validate(CLASSIC_REVOLVE, &front).is_err()); + let n = revolved(&CYLINDER, json!({})); + assert!(validate_revolve(&n, &back).is_err()); + // Changing the type keeps the front's graphics on Revolve's side and drops the back's. + let both = json!({"graphics": [{"symbol": "Red"}, {"symbol": "Blue", "face": "back"}]}); + let kept = three_d_params_as(crate::EXTRUDE, &both, REVOLVE); + assert_eq!(kept["graphics"].as_array().map(Vec::len), Some(1)); + assert_eq!(kept["graphics"][0]["symbol"], "Red"); + assert_eq!(three_d_params_as(REVOLVE, &front, crate::INFLATE).get("graphics"), None); +} + +#[test] +fn revolve_classic_draws_no_graphics_its_parameters_carry() { + let d = document(); + let mut n = revolved(&CYLINDER, json!({"graphics": [{"symbol": "Red"}]})); + assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); + // A file can hold graphics on Revolve (Classic), which commands refuse: Revolve (Classic) + // draws its classic surface, without them. + if let Some(fx) = n.appearance.effects.first_mut() { + fx.id = CLASSIC_REVOLVE.into(); + } + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +#[test] +fn a_group_with_no_member_to_carry_graphics_still_checks_their_art() { + let mut d = document(); + let dots: Vec> = (0..2000) + .map(|i| { + let c = Point::new(f64::from(i % 50) * 12.0, f64::from(i / 50) * 12.0); + Arc::new(Node::path( + NodeId(0), + shapes::ellipse(Rect::new(c.x, c.y, c.x + 10.0, c.y + 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }) + .collect(); + d.symbols.push(Symbol { dynamic: false, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), dots)) }); + let n = revolved(&CYLINDER, json!({})); + let mut member = n.clone(); + member.appearance.effects.clear(); + member.appearance.items.clear(); + let mut group = Node::group(NodeId(9), vec![Arc::new(member)]); + group.appearance.effects = n.appearance.effects.clone(); + // No member has a fill or stroke, so none carries the graphics, but art past the limits is + // refused as it is on a group whose member carries them. + let e = validate_in(Some(&d), &group, REVOLVE, &json!({"graphics": [{"symbol": "Dots", "scale": 10_000.0}]})).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); + assert!(validate_in(Some(&d), &group, REVOLVE, &json!({"graphics": [{"symbol": "Red"}]})).is_ok()); +} + +#[test] +fn a_full_turn_draws_a_graphic_on_either_side_of_the_cut_at_the_back() { + let mut d = document(); + d.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(bar(Rect::new(0.0, 0.0, 2.0, 20.0), Color::rgb(1.0, 0.0, 0.0))) }); + // Half a turn around a radius of 50 is about 157 to each side of the middle: turned to face + // the viewer, a graphic just inside either end is drawn. + for x in [155.0, -155.0] { + let n = revolved(&CYLINDER, json!({"rotationY": 180.0, "graphics": [{"symbol": "Thin", "x": x}]})); + assert!(!parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty(), "x {x}"); + } +} + +/// A gray cylinder of radius 50 whose side is 200 edges along the profile, from y 0 to 200. +fn ribbed() -> Vec<(f64, f64)> { + let mut points = vec![(0.0, 0.0)]; + points.extend((0..=200).map(|k| (50.0, f64::from(k)))); + points.push((0.0, 200.0)); + points +} + +/// A symbol `name` of one red path whose contours are `contours`, each a polygon through its +/// points. +fn contours_symbol(name: &str, contours: &[Vec]) -> Symbol { + let mut b = BezPath::new(); + for contour in contours { + b.move_to(contour[0]); + contour[1..].iter().for_each(|p| b.line_to(*p)); + b.close_path(); + } + let art = Node::path(NodeId(0), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)); + Symbol { dynamic: false, name: name.into(), art: Arc::new(art) } +} + +#[test] +fn contours_away_from_a_face_are_not_cut_at_it() { + // 2,000 small squares in two clusters on the back, at opposite corners of the side: the path's + // bounds reach across every face, but no square reaches a face that is drawn. + let squares: Vec> = (0..2000) + .map(|i| { + let (x, y) = if i < 1000 { (0.0, 0.0) } else { (290.0, 190.0) }; + let (x, y) = (x + f64::from(i % 10) * 0.1, y + f64::from(i % 1000 / 10) * 0.1); + vec![Point::new(x, y), Point::new(x + 0.05, y), Point::new(x + 0.05, y + 0.05), Point::new(x, y + 0.05)] + }) + .collect(); + let mut d = document(); + d.symbols.push(contours_symbol("Clusters", &squares)); + let n = revolved(&ribbed(), json!({})); + let p = json!({"graphics": [{"symbol": "Clusters"}]}); + assert_eq!(validate_revolve_in(Some(&d), &n, &p), Ok(())); +} + +#[test] +fn a_contour_that_reaches_every_face_and_draws_on_none_is_refused() { + // One contour of 10,000 points: a thin frame around the whole side, past its ends and on the + // back, so it is cut at every face and keeps nothing. + let edge = |from: Point, to: Point| (0..2500).map(move |k| from.lerp(to, f64::from(k) / 2500.0)); + let (outer, inner) = ([(0.0, 0.0), (400.0, 0.0), (400.0, 400.0), (0.0, 400.0)], [(1.0, 1.0), (1.0, 399.0), (399.0, 399.0), (399.0, 1.0)]); + let ring = |corners: [(f64, f64); 4]| (0..4).flat_map(move |k| edge(corners[k].into(), corners[(k + 1) % 4].into())).collect::>(); + let mut frame = ring(outer); + frame.extend(ring(inner)); + let mut d = document(); + d.symbols.push(contours_symbol("Frame", &[frame])); + let n = revolved(&ribbed(), json!({})); + let e = validate_revolve_in(Some(&d), &n, &json!({"graphics": [{"symbol": "Frame"}]})).unwrap_err(); + assert!(e.contains("too complex for this surface"), "{e}"); + assert!(parts(&revolve_art_in(Some(&d), &n).unwrap(), false).is_empty()); +} + +/// A red linear gradient of `stops` stops, each with `opacity`. +fn ramp(stops: usize, opacity: f32) -> Paint { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = (0..stops) + .map(|i| { + let mut stop = GradientStop::new(i as f32 / stops as f32, Color::rgb(1.0, 0.0, 0.0)); + stop.opacity = opacity; + stop + }) + .collect(); + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))) +} + +#[test] +fn the_copies_of_a_graphics_paint_have_a_budget_of_their_own() { + let mut d = document(); + for (name, opacity) in [("Ramp", 1.0), ("Clear ramp", 0.5)] { + let art = + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 10.0)), Appearance::basic(ramp(4096, opacity), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + let p = json!({"graphics": [{"symbol": "Ramp"}]}); + let n = revolved(&CYLINDER, json!({})); + let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") }; + let faces = vectorcraft_three_d::revolve(path, path.bounds().unwrap(), options_of(REVOLVE, &p)).unwrap(); + let parts_of = |symbol: &str, copies: &mut usize| { + let graphics = crate::solids::mapped_graphics(&json!({"graphics": [{"symbol": symbol}]})); + crate::solids::graphics::surface_parts(Some(&d), NodeId(0), &graphics, &faces, &|_, c| Paint::solid(c), copies).map(|p| p.concat().len()) + }; + // Each opaque part copies the paint into its fill and its seam stroke; a part with + // transparent stops takes no seam stroke and copies it once. + let (opaque, clear) = (paint_bytes(&ramp(4096, 1.0)), paint_bytes(&ramp(4096, 0.5))); + let mut used = 0; + let count = parts_of("Ramp", &mut used).unwrap(); + assert!(count >= 2, "the graphic draws on several faces"); + assert_eq!(used, 2 * count * opaque); + let mut used = 0; + assert_eq!(parts_of("Clear ramp", &mut used), Some(count)); + assert_eq!(used, count * clear); + // Past the budget the graphic is refused. + assert_eq!(parts_of("Ramp", &mut (MAX_PAINT_COPIES - opaque)), None); + // A surface whose own gradient takes nearly the whole budget for surfaces still takes the + // graphic, on the object, on one fill of two and on a member of a group. + let stop = paint_bytes(&ramp(2, 1.0)) - paint_bytes(&ramp(1, 1.0)); + let base = paint_bytes(&ramp(1, 1.0)) - stop; + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let full = ramp(((MAX_PAINT_COPIES - paint_copies(faces.len(), &gray)) / (faces.len() * 4) - base) / stop, 1.0); + assert!(MAX_PAINT_COPIES - paint_copies(faces.len(), &gray) - paint_copies(faces.len(), &full) < opaque); + let mut object = n.clone(); + if let Some(AppearanceItem::Fill(f)) = object.appearance.items.first_mut() { + f.paint = ramp(((MAX_PAINT_COPIES - opaque) / (faces.len() * 4) - base) / stop, 1.0); + } + assert_eq!(validate_revolve_in(Some(&d), &object, &p), Ok(())); + let fill = |paint: Paint, params: Value| { + let mut fill = FillLayer::new(paint); + fill.effects.push(crate::new_effect(REVOLVE, ¶ms).unwrap()); + AppearanceItem::Fill(fill) + }; + let mut two = n.clone(); + two.appearance = Appearance { items: vec![fill(gray.clone(), p.clone()), fill(full.clone(), json!({}))], ..Default::default() }.into(); + assert_eq!(crate::solids::validate_three_d_at_in(Some(&d), &two, REVOLVE, &json!({}), (Some(1), 0)), Ok(())); + let mut member = n.clone(); + member.appearance.effects.clear(); + let mut second = member.clone(); + if let Some(AppearanceItem::Fill(f)) = second.appearance.items.first_mut() { + f.paint = full; + } + let mut group = Node::group(NodeId(9), vec![Arc::new(member), Arc::new(second)]); + group.appearance.effects = n.appearance.effects.clone(); + assert_eq!(validate_in(Some(&d), &group, REVOLVE, &p), Ok(())); +} + +/// Whether each part of `art` filled with a gradient has a stroke over its seams. +fn gradient_seams(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |n| { + let items = &n.appearance.items; + let red = |p: &Paint| matches!(p, Paint::Gradient(_)) && shade_of(p, false).is_some(); + if n.path_data().is_some() && matches!(items.first(), Some(AppearanceItem::Fill(f)) if red(&f.paint)) { + out.push(items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))); + } + }); + out +} + +#[test] +fn a_gradient_with_transparent_stops_takes_no_stroke_over_its_seams() { + let mut d = document(); + for (name, opacity) in [("Opaque", 1.0), ("Clear", 0.5)] { + let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 10.0)), Appearance::basic(ramp(2, opacity), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + let seams = |symbol: &str| gradient_seams(&revolve_art_in(Some(&d), &revolved(&CYLINDER, json!({"graphics": [{"symbol": symbol}]}))).unwrap()); + let opaque = seams("Opaque"); + assert!(!opaque.is_empty() && opaque.iter().all(|s| *s), "{opaque:?}"); + let clear = seams("Clear"); + assert!(!clear.is_empty() && clear.iter().all(|s| !*s), "{clear:?}"); +} diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index aeec471bcd..9b364c3d3a 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -21,13 +21,14 @@ pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLO mod source; pub(crate) use source::{resolve, resolve_for}; -mod graphics; +pub(crate) mod graphics; pub use graphics::{ GRAPHICS, GraphicFrame, MAX_GRAPHICS, MappedGraphic, graphic_symbols, mapped_graphics, missing_symbol, names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, takes_graphics, with_graphic, }; pub use graphics::{ - MAX_OFFSET as GRAPHIC_MAX_OFFSET, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, rename_appearance_graphic_symbol, + MAX_OFFSET as GRAPHIC_MAX_OFFSET, NOT_TAKEN as GRAPHICS_NOT_TAKEN, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, + rename_appearance_graphic_symbol, }; pub(crate) mod shadow; @@ -80,8 +81,9 @@ pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value { } } } - // A plane has only a front, so graphics on the back cap are dropped. - if to == ROTATE + // A plane has only a front, and Revolve's graphics go on its side, named the front, so graphics + // on the back cap are dropped. + if (to == ROTATE || to == crate::REVOLVE) && let Some(Value::Array(list)) = out.get_mut(GRAPHICS) { list.retain(|g| g.get("face").and_then(Value::as_str) != Some(PlanarFace::Back.id())); @@ -505,7 +507,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va (Some(_), _) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } - (Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index), + (Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(doc, n, id, p, item, index), (None, _) if crate::revolve::is_revolve(id) => crate::revolve::validate_in(doc, n, id, p), (item, index) => validate_solid_for(doc, n, id, p, (item, Some(index))), } @@ -1840,9 +1842,10 @@ pub fn graphic_frame(doc: Option<&Document>, n: &Node, item: Option) -> O } /// [`graphic_frame`] of the 3D effect at `index` in that list, which must be a visible Extrude & -/// Bevel or Rotate. +/// Bevel, Rotate or Revolve. Revolve's frame is its unrolled side seen from the front, centered on +/// the origin, with no face maps: it sizes a new graphic and places no handles. pub fn graphic_frame_at(doc: Option<&Document>, n: &Node, item: Option, index: usize) -> Option { - frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && SOLIDS.contains(&e.id.as_str())).map(|e| (index, e))) + frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && is_three_d(&e.id)).map(|e| (index, e))) } /// Does a visible geometry effect come after the 3D effect at `index` in the effects of `n` (on the @@ -1886,6 +1889,21 @@ fn frame_of(doc: Option<&Document>, n: &Node, item: Option, pick: &Pick<' } let p = crate::merged_params(&fx.id, &fx.params); let b = union_bounds(&shapes)?; + if fx.id == crate::REVOLVE { + // Revolve's graphics lie on its unrolled side (in a group, the side of the member with the + // longest profile): the frame is the part of it seen from the front, with no planar face + // to place a handle on. + let options = crate::revolve::revolve_options(&p); + let (w, h) = shapes + .iter() + .filter(|s| crate::revolve::surface_item(&s.items).is_some()) + .filter_map(|s| vectorcraft_three_d::revolve_surface_size(&s.path, b, options)) + .fold((0.0f64, 0.0f64), |best, size| if size.1 > best.1 { size } else { best }); + if w <= 0.0 || h <= 0.0 { + return None; + } + return Some(graphics::GraphicFrame::new(Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0), None, None)); + } let view = view_options(&p); if fx.id == ROTATE { let plane = vectorcraft_three_d::plane_face_map(b, view); diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs index 6c5d8686a7..7c0087e15b 100644 --- a/crates/effects/src/solids/graphics.rs +++ b/crates/effects/src/solids/graphics.rs @@ -8,6 +8,11 @@ //! seen from the front of that face), its size as a share of the symbol's own, and its turn //! (degrees, counterclockwise). On the back cap, x runs to the right as the back is seen from //! behind, so the graphic reads the right way round there. +//! +//! On Revolve the graphics lie on the swept side surface, unrolled flat +//! ([`vectorcraft_three_d::SurfaceCell`]): x and y are the offset of the graphic's center from the +//! middle of that surface, x around the axis and y along the profile, and each face of the side +//! draws the part of the graphics that falls on it ([`surface_parts`]). use std::sync::Arc; @@ -15,7 +20,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol}; use vectorcraft_geom::kurbo::{CubicBez, ParamCurve, PathEl, Shape}; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use vectorcraft_three_d::{FaceMap, Facet, PlanarFace}; /// The effect parameter that holds the graphics. @@ -41,6 +46,33 @@ const MAX_NESTING: usize = 8; /// places: as deep as an object may be drawn. const MAX_LEVELS: usize = 4 * vectorcraft_doc::MAX_DEPTH; +/// The error for graphics on a 3D effect that takes none. +pub const NOT_TAKEN: &str = "Graphics map onto the Plane, Extrude and Revolve object types"; + +/// The buckets around the unrolled side surface that the pieces of Revolve's graphics are sorted +/// into, so each face looks only at the pieces near it. +const SURFACE_BUCKETS: usize = 256; + +/// How closely the curves of a graphic on Revolve's side follow their shape when they are cut into +/// straight pieces, in points of the unrolled surface. +const SURFACE_TOLERANCE: f64 = 0.05; + +/// How far from the middle of Revolve's unrolled side a curve of a graphic may reach, in points: +/// as far as a Revolve profile's coordinates may. +const SURFACE_REACH: f64 = 1e8; + +/// The most points the parts of Revolve's graphics draw on its faces together, a graphic's area cut +/// at every face it crosses. Past it no graphic is drawn on that surface. +const MAX_SURFACE_POINTS: usize = 2_000_000; + +/// The most points of Revolve's graphics cut at its faces together, counted before each cut whether +/// or not it keeps any of them. Past it no graphic is drawn on that surface. +const MAX_SURFACE_CUTS: usize = 32 * MAX_SURFACE_POINTS; + +/// The width of the stroke of the same paint around each opaque part of a graphic on Revolve's +/// side, which covers the seams between the parts on neighboring faces. +const SURFACE_SEAM: f64 = 0.3; + /// The size range of a graphic, as a percentage of its symbol's own size. pub const SCALE_RANGE: (f64, f64) = (1.0, 10_000.0); @@ -116,10 +148,10 @@ impl MappedGraphic { } } -/// The 3D effects that take graphics: Extrude & Bevel (its caps) and Rotate (the plane), the two -/// object types of the 3D and Materials panel with planar faces. +/// The 3D effects that take graphics: Extrude & Bevel (its caps), Rotate (the plane) and Revolve +/// (its swept side). pub fn takes_graphics(id: &str) -> bool { - id == super::EXTRUDE || id == super::ROTATE + id == super::EXTRUDE || id == super::ROTATE || id == crate::REVOLVE } /// The graphics in parameters `p`, leaving out entries that are not valid (a file may hold @@ -131,8 +163,9 @@ pub fn mapped_graphics(p: &Value) -> Vec { } } -/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel and Rotate take -/// graphics, and a plane has only a front. +/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel, Rotate and +/// Revolve take graphics; a plane has only a front, and Revolve's graphics go on its side, named +/// the front. pub fn validate(id: &str, p: &Value) -> Result<(), String> { let list = match p.get(GRAPHICS) { None => return Ok(()), @@ -143,7 +176,7 @@ pub fn validate(id: &str, p: &Value) -> Result<(), String> { return Ok(()); } if !takes_graphics(id) { - return Err("Graphics map onto planar faces: use the Extrude or Plane object type".into()); + return Err(NOT_TAKEN.into()); } if list.len() > MAX_GRAPHICS { return Err(format!("`{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics")); @@ -153,6 +186,9 @@ pub fn validate(id: &str, p: &Value) -> Result<(), String> { if id == super::ROTATE && g.face == PlanarFace::Back { return Err("A plane has one face: map its graphics onto the front".into()); } + if id == crate::REVOLVE && g.face == PlanarFace::Back { + return Err("Revolve maps graphics onto its side: map them onto the front".into()); + } } Ok(()) } @@ -483,6 +519,411 @@ pub(super) fn face_art( Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, children))], clip: true })) } +/// One area of a graphic placed on Revolve's unrolled side surface, cut into straight pieces. +struct Unrolled { + polygons: Vec>, + /// The bounds of each of `polygons`. + boxes: Vec, + bounds: Rect, + rule: FillRule, + paint: Paint, + opacity: f32, + blend: BlendMode, + /// Where the graphic's art goes on the surface: its gradients and patterns are placed with it. + place: Affine, +} + +/// The areas of `graphics` (those of the front: Revolve's side) placed on the unrolled side +/// surface, cut into straight pieces. A graphic whose symbol `doc` does not have, or whose art is +/// past the limits of [`pieces_of`], is left out. `None` when the pieces take more than +/// [`MAX_ELEMENTS`] points together. +fn unrolled(doc: &Document, graphics: &[MappedGraphic]) -> Option> { + let mut pieces = vec![]; + let mut points = 0usize; + for g in graphics.iter().filter(|g| g.face == PlanarFace::Front) { + let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; + let Ok(art) = pieces_of(doc, symbol) else { continue }; + let Some(bounds) = art.iter().filter_map(|p| p.path.bounds()).reduce(|a, b| a.union(b)) else { continue }; + let place = g.placement(bounds, Point::ZERO); + for piece in art { + let placed = place * piece.path.to_bezpath(); + let mut polygons = straight_pieces(&placed, &mut points)?; + polygons.retain(|p| p.len() >= 3 && p.iter().all(|q| q.is_finite())); + let boxes: Vec = + polygons.iter().filter_map(|polygon| polygon.iter().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b))).collect(); + let Some(bounds) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { continue }; + pieces.push(Unrolled { + polygons, + boxes, + bounds, + rule: piece.rule, + paint: piece.paint, + opacity: piece.opacity, + blend: piece.blend, + place, + }); + } + } + Some(pieces) +} + +/// The contours of `path` cut into straight pieces within [`SURFACE_TOLERANCE`], adding the points +/// made to `points`. Each curve is cut on its own and the count is checked before the next one, so +/// the work stops at the first curve past [`MAX_ELEMENTS`] points: `None` then, and for a path with +/// a coordinate of a line or a curve farther than [`SURFACE_REACH`] from the origin, which bounds +/// the pieces of one curve and the arithmetic of the cuts at the faces. +fn straight_pieces(path: &BezPath, points: &mut usize) -> Option>> { + let near = |p: &Point| p.x.abs() <= SURFACE_REACH && p.y.abs() <= SURFACE_REACH; + let mut polygons: Vec> = vec![]; + let mut current: Vec = vec![]; + let (mut start, mut last) = (Point::ZERO, Point::ZERO); + for el in path.elements() { + if *points > MAX_ELEMENTS { + return None; + } + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) if !near(&p) => return None, + PathEl::MoveTo(p) => { + polygons.push(std::mem::take(&mut current)); + current.push(p); + *points += 1; + (start, last) = (p, p); + } + PathEl::LineTo(p) => { + current.push(p); + *points += 1; + last = p; + } + PathEl::ClosePath => { + polygons.push(std::mem::take(&mut current)); + last = start; + } + PathEl::QuadTo(a, p) | PathEl::CurveTo(_, a, p) => { + let first = if let PathEl::CurveTo(b, ..) = *el { b } else { a }; + if ![last, first, a, p].iter().all(near) { + return None; + } + vectorcraft_geom::kurbo::flatten([PathEl::MoveTo(last), *el], SURFACE_TOLERANCE, |cut| { + if let PathEl::LineTo(q) = cut { + current.push(q); + *points += 1; + } + }); + last = p; + } + } + } + polygons.push(current); + (*points <= MAX_ELEMENTS).then_some(polygons) +} + +/// Do the graphics in Revolve parameters `p` fit the limits with the symbols of `doc`? Their art, +/// cut into straight pieces on the side surface, may take up to [`MAX_ELEMENTS`] points together. +pub(crate) fn validate_surface_symbols(doc: &Document, p: &Value) -> Result<(), String> { + let list = mapped_graphics(p); + if list.is_empty() || unrolled(doc, &list).is_some() { + return Ok(()); + } + Err("The graphics are too complex: simplify the symbols' art".into()) +} + +/// The parts of `graphics` that fall on each of `faces`, a revolved surface's faces in drawing +/// order, for an object with id `id`: for each face, the nodes to draw right after it. Only faces +/// of the swept side that show their outside to the viewer carry a part, so a graphic narrows +/// toward the silhouette, is hidden past it and is covered by the nearer faces drawn after it. +/// `shade` gives the paint of a solid color on a face, as the surface under it is shaded; the +/// colors of gradients and patterns are multiplied by the face's light. A graphic whose symbol +/// `doc` does not have, or that is past the limits of its art, draws nothing. `None` past +/// [`MAX_ELEMENTS`] points together before they are cut at the faces, [`MAX_SURFACE_POINTS`] +/// after, [`MAX_SURFACE_CUTS`] points cut, or when the copies of their paints in the parts take +/// `copies`, the bytes of the copies of the object's graphics so far, past Revolve's budget for +/// them: commands refuse such graphics, and a loaded file draws the surface without them. +pub(crate) fn surface_parts( + doc: Option<&Document>, + id: NodeId, + graphics: &[MappedGraphic], + faces: &[vectorcraft_three_d::Face], + shade: &dyn Fn(&vectorcraft_three_d::Face, vectorcraft_doc::color::Color) -> Paint, + copies: &mut usize, +) -> Option>>> { + let mut out: Vec>> = vec![vec![]; faces.len()]; + let Some(doc) = doc else { return Some(out) }; + let pieces = unrolled(doc, graphics)?; + let Some(all) = pieces.iter().map(|p| p.bounds).reduce(|a, b| a.union(b)) else { return Some(out) }; + // Each piece listed in the buckets of the surface's x its bounds reach across. + let width = (all.width() / SURFACE_BUCKETS as f64).max(1e-9); + let bucket = |x: f64| (((x - all.x0) / width).floor().max(0.0) as usize).min(SURFACE_BUCKETS - 1); + let mut buckets: Vec> = vec![vec![]; SURFACE_BUCKETS]; + for (k, p) in pieces.iter().enumerate() { + for b in buckets.get_mut(bucket(p.bounds.x0)..=bucket(p.bounds.x1)).into_iter().flatten() { + b.push(k); + } + } + let mut seen = vec![usize::MAX; pieces.len()]; + let mut drawn = 0usize; + let mut cut_points = 0usize; + let mut triangle_id = 0usize; + for (face, parts) in faces.iter().zip(out.iter_mut()) { + let Some(cell) = face.surface.as_ref().filter(|c| c.outward && !face.cap) else { continue }; + if cell.uv.len() != cell.page.len() || !(3..=4).contains(&cell.uv.len()) { + continue; + } + let triangles: &[[usize; 3]] = if cell.uv.len() == 3 { &[[0, 1, 2]] } else { &[[0, 1, 2], [0, 2, 3]] }; + for corners in triangles { + triangle_id += 1; + let pick = |of: &[Point]| corners.iter().map(|k| of.get(*k).copied()).collect::>>(); + let (Some(uv), Some(page)) = (pick(&cell.uv), pick(&cell.page)) else { continue }; + let (Some(map), Some(window)) = (triangle_map(&uv, &page), counterclockwise(&uv)) else { continue }; + // A face along an end cap's ring has that edge curved on the page: its parts are cut + // from a larger triangle and then to the face's own outline, so they reach the curve + // where the graphic runs past the end of the profile. A four-cornered face is cut into + // two triangles along its diagonal from corner 0 to corner 2: each takes the part of + // the outline on its own side of it. + let window = if face.outline.is_some() { grown(window, 3.0) } else { window }; + // The pieces are looked up over the whole of the window they are cut to. + let Some(reach) = window.iter().map(|p| Rect::from_points(*p, *p)).reduce(|a, b| a.union(b)) else { continue }; + if reach.x1 < all.x0 || reach.x0 > all.x1 || reach.y1 < all.y0 || reach.y0 > all.y1 { + continue; + } + // The pieces near the triangle, each once, in the graphics' drawing order. + let mut near = vec![]; + for b in buckets.get(bucket(reach.x0)..=bucket(reach.x1)).into_iter().flatten() { + for &k in b { + let Some(slot) = seen.get_mut(k) else { continue }; + if *slot != triangle_id { + *slot = triangle_id; + near.push(k); + } + } + } + near.retain(|k| { + pieces + .get(*k) + .is_some_and(|p| p.bounds.x1 >= reach.x0 && p.bounds.x0 <= reach.x1 && p.bounds.y1 >= reach.y0 && p.bounds.y0 <= reach.y1) + }); + if near.is_empty() { + continue; + } + near.sort_unstable(); + let own = corners.iter().find(|k| **k != 0 && **k != 2).and_then(|k| cell.page.get(*k)).filter(|_| triangles.len() == 2); + let rim = face.outline.as_ref().map(|outline| match (own, cell.page.first(), cell.page.get(2)) { + (Some(&toward), Some(&a), Some(&b)) => { + let side = side_of(a, b, toward); + vectorcraft_pathops::boolean(outline, FillRule::NonZero, &side, FillRule::NonZero, vectorcraft_pathops::BoolOp::Intersect) + } + _ => outline.clone(), + }); + let rim = rim.as_ref(); + for k in near { + let Some(piece) = pieces.get(k) else { continue }; + let mut path = BezPath::new(); + // Each contour is cut only where its own bounds reach the triangle. + for (polygon, b) in piece.polygons.iter().zip(&piece.boxes) { + if b.x1 < reach.x0 || b.x0 > reach.x1 || b.y1 < reach.y0 || b.y0 > reach.y1 { + continue; + } + cut_points = cut_points.saturating_add(polygon.len()); + if cut_points > MAX_SURFACE_CUTS { + return None; + } + let cut = clip_to(polygon, &window); + let Some((first, rest)) = cut.split_first() else { continue }; + path.move_to(map * *first); + rest.iter().for_each(|q| path.line_to(map * *q)); + path.close_path(); + drawn = drawn.saturating_add(cut.len()); + } + if drawn > MAX_SURFACE_POINTS { + return None; + } + if path.elements().is_empty() { + continue; + } + let (path, rule) = match rim { + Some(outline) => { + let cut = vectorcraft_pathops::boolean( + &PathData::from_bezpath(&path), + piece.rule, + outline, + FillRule::NonZero, + vectorcraft_pathops::BoolOp::Intersect, + ); + (cut.to_bezpath(), FillRule::NonZero) + } + None => (path, piece.rule), + }; + if path.elements().is_empty() { + continue; + } + // Each part copies its paint into its fill, and into the stroke over its seams when + // it takes one. + if !crate::revolve::paint_copies_add(copies, 1 + usize::from(takes_seam(piece)), &piece.paint) { + return None; + } + parts.push(Arc::new(surface_piece(id, piece, (path, rule), map, face, shade))); + } + } + } + Some(out) +} + +/// The node of the part `path` (on the page, filled under `rule`) of `piece`, which `map` takes +/// from the unrolled surface onto `face`. +fn surface_piece( + id: NodeId, + piece: &Unrolled, + (path, rule): (BezPath, FillRule), + map: Affine, + face: &vectorcraft_three_d::Face, + shade: &dyn Fn(&vectorcraft_three_d::Face, vectorcraft_doc::color::Color) -> Paint, +) -> Node { + let gradient_map = map * piece.place; + // Under several lights a face's light varies across it in each channel: a solid color draws + // as the face's own lit art in that color, clipped to the part. An opaque part also draws that + // art clipped to a band of the seam stroke's width around its edges, as the other parts take + // a stroke over their seams. + if let (Some(c), Some(light)) = (piece.paint.color().filter(|_| matches!(piece.paint, Paint::Solid { .. })), face.rgb_gradient) { + let lit = Arc::new(crate::lighting::rgb_gradient_art(id, &face.path(), &Paint::solid(c), light)); + let clipped = |area: &BezPath, rule: FillRule| { + let mut clip = Node::path(id, PathData::from_bezpath(area), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut clip.kind { + *r = rule; + } + Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), lit.clone()], clip: true }) + }; + let mut group = clipped(&path, rule); + if takes_seam(piece) { + let style = vectorcraft_geom::kurbo::Stroke::new(SURFACE_SEAM).with_join(vectorcraft_geom::kurbo::Join::Round); + let band = vectorcraft_geom::kurbo::stroke(path.iter(), &style, &vectorcraft_geom::kurbo::StrokeOpts::default(), SURFACE_TOLERANCE); + group = Node::group(id, vec![Arc::new(group), Arc::new(clipped(&band, FillRule::NonZero))]); + } + group.opacity = piece.opacity; + group.blend = piece.blend; + return group; + } + let mut paint = piece.paint.clone(); + if let Paint::Pattern { xf, .. } = &mut paint { + *xf = gradient_map * *xf; + } + let solid = paint.color().filter(|_| matches!(paint, Paint::Solid { .. })); + let mut fill = FillLayer::new(paint); + fill.opacity = piece.opacity; + fill.blend = piece.blend; + let mut appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }; + appearance.transform_gradients(gradient_map); + for item in appearance.items.iter_mut() { + if let AppearanceItem::Fill(f) = item { + match solid { + Some(c) => f.paint = shade(face, c), + None => { + vectorcraft_doc::recolor::map_paint(&mut f.paint, &|c| crate::lighting::shade(c, face.illumination)); + } + } + } + } + // Parts on neighboring faces meet edge to edge: an opaque part takes a narrow stroke of its + // paint over the seam, as the faces do. + if takes_seam(piece) + && let Some(AppearanceItem::Fill(f)) = appearance.items.first() + { + let mut seam = vectorcraft_doc::StrokeLayer::new(f.paint.clone(), SURFACE_SEAM); + seam.join = vectorcraft_doc::LineJoin::Round; + appearance.items.push(AppearanceItem::Stroke(seam)); + } + let mut node = Node::path(id, PathData::from_bezpath(&path), appearance); + if let NodeKind::Path { rule: r, .. } = &mut node.kind { + *r = rule; + } + node +} + +/// Does each part of `piece` take a stroke of its paint over its seams? Only an opaque part does: +/// the overlap of a transparent one, of one whose gradient has a stop or a freeform point that is +/// not fully opaque, or of a pattern, whose tile may hold transparent art, would show. +fn takes_seam(piece: &Unrolled) -> bool { + let clear = match &piece.paint { + Paint::Gradient(g) => { + g.gradient.stops.iter().any(|s| s.opacity < 1.0) || g.freeform.as_ref().is_some_and(|f| f.points.iter().any(|p| p.opacity < 1.0)) + } + Paint::Pattern { .. } => true, + _ => false, + }; + piece.opacity >= 1.0 && piece.blend == BlendMode::Normal && !clear +} + +/// A region far larger than the face on the side of the line through `a` and `b` that `toward` is +/// on: a rectangle with one edge along the line, reaching a thousand times the line's length past +/// its ends and away from it. +fn side_of(a: Point, b: Point, toward: Point) -> PathData { + let along = b - a; + let mut across = Vec2::new(-along.y, along.x); + if across.dot(toward - a) < 0.0 { + across = -across; + } + let (far, wide) = (across * 1000.0, along * 1000.0); + let mut out = BezPath::new(); + out.move_to(a - wide); + out.line_to(b + wide); + out.line_to(b + wide + far); + out.line_to(a - wide + far); + out.close_path(); + PathData::from_bezpath(&out) +} + +/// Triangle `t` scaled by `factor` about its centroid, wound as it was. +fn grown(t: [Point; 3], factor: f64) -> [Point; 3] { + let c = Point::new((t[0].x + t[1].x + t[2].x) / 3.0, (t[0].y + t[1].y + t[2].y) / 3.0); + t.map(|p| c + (p - c) * factor) +} + +/// The affine map that takes the corners `from` of a triangle onto `to`. `None` for a triangle +/// with no area or a map that is not finite. +fn triangle_map(from: &[Point], to: &[Point]) -> Option { + let ([u0, u1, u2], [p0, p1, p2]) = (<[Point; 3]>::try_from(from).ok()?, <[Point; 3]>::try_from(to).ok()?); + let (a, b) = (u1 - u0, u2 - u0); + let det = a.x * b.y - b.x * a.y; + if det.abs() <= 1e-12 * (a.hypot2() + b.hypot2()).max(1e-300) { + return None; + } + let (c, d) = (p1 - p0, p2 - p0); + let m = [(c.x * b.y - d.x * a.y) / det, (c.y * b.y - d.y * a.y) / det, (d.x * a.x - c.x * b.x) / det, (d.y * a.x - c.y * b.x) / det]; + let xf = Affine::new([m[0], m[1], m[2], m[3], p0.x - m[0] * u0.x - m[2] * u0.y, p0.y - m[1] * u0.x - m[3] * u0.y]); + xf.as_coeffs().iter().all(|v| v.is_finite()).then_some(xf) +} + +/// Triangle `t` wound counterclockwise (y up), as [`clip_to`] reads its window. `None` without +/// three corners. +fn counterclockwise(t: &[Point]) -> Option<[Point; 3]> { + let [a, b, c] = <[Point; 3]>::try_from(t).ok()?; + Some(if (b - a).cross(c - a) < 0.0 { [a, c, b] } else { [a, b, c] }) +} + +/// The part of `polygon` inside the convex `window` (one step of Sutherland and Hodgman's clip for +/// each of its edges). A polygon that is not convex keeps its area, with edges along the window's +/// sides where it leaves and comes back. +fn clip_to(polygon: &[Point], window: &[Point; 3]) -> Vec { + let mut out = polygon.to_vec(); + for k in 0..3 { + let (a, b) = (window[k], window[(k + 1) % 3]); + let side = |p: Point| (b - a).cross(p - a); + let input = std::mem::take(&mut out); + for (i, &p) in input.iter().enumerate() { + let q = input[(i + 1) % input.len()]; + let (sp, sq) = (side(p), side(q)); + if sp >= 0.0 { + out.push(p); + } + if (sp >= 0.0) != (sq >= 0.0) { + out.push(p.lerp(q, sp / (sp - sq))); + } + } + if out.len() < 3 { + return vec![]; + } + } + out +} + /// The bounds of the art symbol `name` of `doc` draws as a graphic, in the symbol's coordinates. pub(super) fn art_bounds(doc: &Document, name: &str) -> Option { let symbol = doc.symbols.iter().find(|s| s.name == name)?; diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index bf219353cf..e0935a43e7 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -505,7 +505,10 @@ fn graphics_are_checked() { } assert!(check(ROTATE, json!([{"symbol": "Mark", "face": "back"}])).is_err()); assert!(check(INFLATE, json!([{"symbol": "Mark"}])).is_err()); - assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err()); + // Revolve takes graphics on its side, named the front; Revolve (Classic) takes none. + assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_ok()); + assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark", "face": "back"}]})).is_err()); + assert!(validate_three_d_params(crate::CLASSIC_REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err()); // A loaded file's invalid entries are left out when drawn. assert_eq!(mapped_graphics(&json!({"graphics": [{"symbol": "Mark"}, {"face": "back"}, 3]})).len(), 1); } @@ -796,3 +799,72 @@ fn a_symbol_instance_in_a_graphic_draws_as_the_canvas_shows_it() { let area: f64 = pieces.iter().map(|p| p.path.to_bezpath().area().abs()).sum(); assert!((area - 30_000.0).abs() < 1e-3, "{area}"); } + +#[test] +fn curves_are_cut_one_at_a_time_and_the_work_stops_at_the_limit() { + let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 6.0)).to_bezpath(); + // Cut curve by curve, a path gives the points it gives cut whole. + for scale in [1.0, 100.0, 10_000.0] { + let placed = Affine::scale(scale) * ellipse.clone(); + let mut whole = vec![]; + vectorcraft_geom::kurbo::flatten(placed.iter(), SURFACE_TOLERANCE, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + whole.push(p); + } + }); + let mut points = 0; + let cut: Vec = straight_pieces(&placed, &mut points).unwrap().into_iter().flatten().collect(); + assert_eq!(points, cut.len()); + assert_eq!(cut.len(), whole.len(), "{scale}"); + assert!(cut.iter().zip(&whole).all(|(a, b)| a.distance(*b) < 1e-9), "{scale}"); + } + // Past the limit no further curve is cut: of 1,000 ellipses that each take more than 3,000 + // points, the count stops within one curve of the limit. + let mut many = BezPath::new(); + (0..1000).for_each(|_| many.extend(Affine::scale(10_000.0) * ellipse.clone())); + let mut points = 0; + assert!(straight_pieces(&many, &mut points).is_none()); + assert!(points > MAX_ELEMENTS && points < MAX_ELEMENTS + 2000, "{points}"); + // A curve that reaches farther than any surface, or to no finite point, is not cut at all. + for scale in [1e12, f64::INFINITY] { + let mut points = 0; + assert!(straight_pieces(&(Affine::scale(scale) * ellipse.clone()), &mut points).is_none()); + assert!(points <= 1, "{scale}: {points}"); + } +} + +#[test] +fn a_line_that_reaches_farther_than_any_surface_is_not_cut() { + let triangle = |far: f64| { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((far, 0.0)); + b.line_to((0.0, 1.0)); + b.close_path(); + b + }; + assert!(straight_pieces(&triangle(1e7), &mut 0).is_some()); + for far in [1e12, 1e300] { + assert!(straight_pieces(&triangle(far), &mut 0).is_none(), "{far}"); + assert!(straight_pieces(&(Affine::translate((far, 0.0)) * triangle(1.0)), &mut 0).is_none(), "{far}"); + } +} + +#[test] +fn only_an_opaque_part_on_revolve_takes_a_seam_stroke() { + let piece = |paint: Paint, opacity: f32, blend: BlendMode| Unrolled { + polygons: vec![], + boxes: vec![], + bounds: Rect::ZERO, + rule: FillRule::NonZero, + paint, + opacity, + blend, + place: Affine::IDENTITY, + }; + assert!(takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Normal))); + assert!(!takes_seam(&piece(Paint::solid(red()), 0.5, BlendMode::Normal))); + assert!(!takes_seam(&piece(Paint::solid(red()), 1.0, BlendMode::Multiply))); + // A pattern's tile may hold transparent art, which the overlap would show. + assert!(!takes_seam(&piece(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, 1.0, BlendMode::Normal))); +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 56f9d40633..48665539d9 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -5,6 +5,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; mod imageoutline; +mod painted; mod rasterfollow; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/painted.rs b/crates/effects/src/tests/painted.rs new file mode 100644 index 0000000000..31dadc865e --- /dev/null +++ b/crates/effects/src/tests/painted.rs @@ -0,0 +1,270 @@ +//! Live-effect plug-ins that paint what they return, run on each fill a painted effect returned. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath, shapes}; +use vectorcraft_plugins::wat as tpl; + +use crate::geometry::Budget; +use crate::reshape; +use crate::reshape::{HELD_PEAK, PLUGIN_RUNS}; + +/// Installs live-effect plug-in `id`, which paints what it returns, replacing one with the same id: +/// its effect returns `output` whatever it is given. +fn install_painting(id: &str, output: &serde_json::Value) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); +} + +/// An open path of `curves` tall curves, each drawn with about 5,200 lines. +fn tall_curves(curves: u32) -> PathData { + let h = 3_000_000.0; + let anchors = (0..=curves) + .map(|i| { + let x = 40.0 + f64::from(i); + Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + PathData::single(SubPath::new(anchors, false)) +} + +/// Installs live-effect plug-in `id`, which reshapes the path it is given: its effect returns +/// `output` whatever it is given. +fn install_reshaping(id: &str, output: &serde_json::Value) { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#); + let module = wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); +} + +/// `curves` curves from `x`, each `h` tall at its handles. +fn curves_at(x: f64, curves: u32, h: f64) -> SubPath { + let anchors = (0..=curves) + .map(|i| { + let x = x + f64::from(i); + Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + SubPath::new(anchors, false) +} + +/// An open path, `dx` to the right of 40, drawn with about `lines` lines: tall curves and one +/// curve that makes up the rest. +fn drawn_with(lines: usize, dx: f64) -> PathData { + let h = 3_000_000.0; + let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines; + let tall = u32::try_from(lines / per).unwrap(); + let rest = lines % per; + let mut subpaths = vec![curves_at(40.0 + dx, tall, h)]; + if rest > 0 { + // A curve `h` tall at its handles is drawn with about √(9h) lines. + let rest = rest as f64; + subpaths.push(curves_at(40.0 + dx + f64::from(tall) + 2.0, 1, rest * rest / 9.0)); + } + PathData::new(subpaths) +} + +/// Stacks a painted effect that returns `first` on a path drawn with about `lines` lines with five +/// later effects on a square's fill, the `k`th returning that path `k` to the right (`later` +/// installs each). Each piece's share of the budget is counted once, so every effect fits, and the +/// art ends on the last effect's path, painted by `paints`. +fn later_effects_count_each_piece_once(name: &str, lines: usize, first: &serde_json::Value, later: fn(&str, &serde_json::Value), paints: &[Paint]) { + let near = drawn_with(lines, 0.0); + let drawn = Budget::of(&near).lines; + assert!(drawn.abs_diff(lines) <= 2, "the path is drawn with {drawn} lines"); + let first_id = format!("test.effects.{name}.first"); + let mut first = first.clone(); + first["objects"][0]["path"] = serde_json::to_value(&near).unwrap(); + install_painting(&first_id, &first); + let mut ids = vec![format!("plugin.{first_id}")]; + for k in 1..=5u8 { + let id = format!("test.effects.{name}.later{k}"); + later(&id, &json!({"objects": [{"path": drawn_with(lines, f64::from(k))}]})); + ids.push(format!("plugin.{id}")); + } + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for id in ids { + n.appearance.items[fill].effects_mut().push(Effect { id, params: json!({}), visible: true }); + } + let art = reshape(None, &n, None).unwrap(); + let mut paths = vec![]; + // The path is a compound path of two (the tall curves and the one that makes up the rest). + art.walk(&mut |c| { + let bounds = match &c.kind { + NodeKind::Compound { children, .. } => children.iter().filter_map(|k| k.path_data().and_then(PathData::bounds)).reduce(|a, b| a.union(b)), + _ => c.path_data().and_then(PathData::bounds), + }; + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if bounds.is_some() && !paints.is_empty() { + paths.push((bounds, paints)); + } + }); + assert_eq!(paths, [(drawn_with(lines, 5.0).bounds(), paints.to_vec())]); +} + +/// Later geometry effects on the two fills a painted effect returned run on each fill, and the +/// path they both stay on counts its piece's share of the budget once: each fill's result, drawn +/// with about 200 lines fewer than half the budget allows, fits at every stage. +#[test] +fn later_geometry_effects_on_two_fills_count_the_piece_once() { + let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))]; + let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect(); + let lines = Budget::EFFECTS.lines / 2 - 200; + later_effects_count_each_piece_once("twofills", lines, &json!({"objects": [{"fills": fills}]}), install_reshaping, &colors); +} + +/// Later painted effects on a painted effect's piece count the piece's share of the budget once: +/// each run's pieces take the place of the piece they were made of, so a result drawn with about +/// 300 lines fewer than the budget allows fits at every stage. +#[test] +fn later_painted_effects_on_a_painted_piece_count_it_once() { + let lines = Budget::EFFECTS.lines - 300; + later_effects_count_each_piece_once("painted", lines, &json!({"objects": [{}]}), install_painting, &[Paint::solid(Color::BLACK)]); +} + +/// A path's bounds and the paints of its fills and strokes. +type PaintedPath = (Option, Vec); + +/// The paths of the art a painted effect `name` that returns `square` with eight fills makes, each +/// path with its paints, when a later effect returns `first` on the first fill and `rest` on the +/// others, painting what it returns when `paints`; and the eight paints. Checks that the plug-in +/// results kept from one fill to the next never cost more than the effects' budget together. +fn eight_fills_then(name: &str, square: &PathData, paints: bool, results: (&PathData, &PathData)) -> (Vec, Vec) { + let (paths, colors, _) = fills_then(name, 8, square, paints, results); + (paths, colors) +} + +/// [`eight_fills_then`] with `n` fills, and how many times the later effect ran on a fill. +fn fills_then(name: &str, n: u8, square: &PathData, paints: bool, (first, rest): (&PathData, &PathData)) -> (Vec, Vec, usize) { + let colors: Vec = (0..n).map(|i| Paint::solid(Color::rgb(f32::from(i) / f32::from(n), 0.0, 0.0))).collect(); + let fills: Vec<_> = colors.iter().map(|p| serde_json::to_value(p).unwrap()).collect(); + let fills_id = format!("test.effects.{name}.fills"); + install_painting(&fills_id, &json!({"objects": [{"path": square, "fills": fills}]})); + let each_id = format!("test.effects.{name}.each"); + let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" }; + let manifest = format!(r#"{{"id":"{each_id}","name":"Test {each_id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#); + let (on_first, on_rest) = (json!({"objects": [{"path": first}]}).to_string(), json!({"objects": [{"path": rest}]}).to_string()); + let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_rest)).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for id in [fills_id, each_id] { + n.appearance.items[fill].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + } + HELD_PEAK.with(|p| p.set(Budget { anchors: 0, lines: 0 })); + PLUGIN_RUNS.with(|r| r.set(0)); + let art = reshape(None, &n, None).unwrap(); + let runs = PLUGIN_RUNS.with(std::cell::Cell::get); + let mut paths = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if !paints.is_empty() { + paths.push((p.bounds(), paints)); + } + } + }); + let peak = HELD_PEAK.with(std::cell::Cell::get); + assert!(Budget::EFFECTS.fits(peak), "the results kept cost {peak:?}"); + (paths, colors, runs) +} + +/// A later painted effect's results on eight fills, each costing more than half the effects' +/// budget and the first fill's on another path than the rest: only the first fill's result fits, +/// the other fills stay together on the square, and the results kept never cost more than the +/// budget together. +#[test] +fn painted_results_that_do_not_fit_are_not_kept() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let (large, other) = (tall_curves(900), PathData::single(curves_at(3_000.0, 900, 3_000_000.0))); + let (paths, colors) = eight_fills_then("held", &square, true, (&large, &other)); + assert_eq!(paths, [(large.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]); +} + +/// A later effect that reshapes each of eight painted fills to the same path, drawn with about a +/// fifth of the effects' budget: the fills share that one path, and the results that move onto it +/// are not kept as well, so what is kept never costs more than the budget. +#[test] +fn results_on_one_path_are_not_kept_for_each_fill() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let same = tall_curves(300); + let (paths, colors) = eight_fills_then("merged", &square, false, (&same, &same)); + assert_eq!(paths, [(same.bounds(), colors)]); +} + +/// A later effect that reshapes each of eight painted fills to the same path, drawn with more than +/// half the effects' budget: the path costs the art once, so every fill moves onto it, although a +/// second copy of it would not fit. +#[test] +fn results_on_one_path_share_it_near_the_budget() { + let square = shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)); + let large = tall_curves(900); + let (paths, colors) = eight_fills_then("shared", &square, false, (&large, &large)); + assert_eq!(paths, [(large.bounds(), colors)]); +} + +/// An open path of tall curves `dx` to the right of 40, drawn with about `share` of the effects' +/// budget's lines. +fn drawn_share(share: f64, dx: f64) -> PathData { + let h = 3_000_000.0; + let per = Budget::of(&PathData::single(curves_at(0.0, 1, h))).lines as f64; + let curves = (Budget::EFFECTS.lines as f64 * share / per) as u32; + PathData::single(curves_at(40.0 + dx, curves, h)) +} + +/// A later painted effect runs once on each of three fills although the results are placed twice: +/// a result that fits both placings is kept between them, and one that did not fit in the first is +/// not run again when it does not fit the second either. The first fill's result (a tenth of the +/// budget) and the second's (half of it) fit, the third's (half of it) does not, and the square +/// (45%) left for the third fill then has no room; in the second placing the second fill's result +/// does not fit beside the square kept for the fills after it. +#[test] +fn a_later_plug_in_runs_once_on_each_fill_across_both_placings() { + let (square, first, rest) = (drawn_share(0.45, 0.0), drawn_share(0.1, 2_000.0), drawn_share(0.5, 4_000.0)); + let (paths, colors, runs) = fills_then("once", 3, &square, true, (&first, &rest)); + assert_eq!(paths, [(first.bounds(), colors[..1].to_vec()), (square.bounds(), colors[1..].to_vec())]); + assert_eq!(runs, 3, "the later effect ran {runs} times on three fills"); +} + +/// A built-in geometry effect on two strokes that a painted effect returned, which doubles the lines +/// of a path drawn with 30% of the budget: the second stroke's result does not fit beside the +/// first's, but it is the same path, so it is built again with all the room the art leaves and +/// both strokes are drawn on that one path. +#[test] +fn a_built_in_result_on_the_path_before_it_is_taken_near_the_budget() { + let square = drawn_share(0.3, 0.0); + let transform = json!({"scaleV": 400}); + let tall = crate::distort::transform_within(&square, square.bounds().unwrap(), &transform, 0, usize::MAX); + let (s, t) = (Budget::of(&square).lines, Budget::of(&tall).lines); + let lines = Budget::EFFECTS.lines; + assert!(t > lines / 2 + lines / 20 && t < lines - lines / 20 && s + t < lines, "the paths are drawn with {s} and {t} lines"); + let colors = [Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 1.0, 0.0))]; + let strokes: Vec<_> = colors.iter().map(|p| json!({"paint": p, "width": 1})).collect(); + let id = "test.effects.builtin.strokes"; + install_painting(id, &json!({"objects": [{"path": square, "strokes": strokes}]})); + let mut n = + Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let stroke = n.appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + n.appearance.items[stroke].effects_mut().push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + n.appearance.items[stroke].effects_mut().push(Effect { id: "distort.transform".into(), params: transform, visible: true }); + let art = reshape(None, &n, None).unwrap(); + let mut paths = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + let paints: Vec = c.appearance.items.iter().map(|i| i.paint().clone()).filter(|p| !p.is_none()).collect(); + if !paints.is_empty() { + paths.push((p.bounds(), paints)); + } + } + }); + // One path, scaled about a reference point of the piece's own, painted by both strokes. + assert_eq!(paths.len(), 1, "{paths:?}"); + let (bounds, paints) = &paths[0]; + assert_eq!(paints, &colors.to_vec()); + let height = |b: Option| b.map_or(0.0, |b| b.height()); + assert!((height(*bounds) - height(tall.bounds())).abs() < 1.0, "{bounds:?} is not {:?} tall", tall.bounds()); +} 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.rs b/crates/engine/src/cmd.rs index af768284b0..166436b0c8 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -245,6 +245,12 @@ pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> { let Some(root) = ordinary_isolation(st) else { return has_selection(s) }; if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) } } +/// [`has_selected_member`], or a ruler guide is selected outside isolation mode: Object › Lock › +/// Selection and Object › Hide › Selection. +pub fn has_selected_member_or_guide(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if ordinary_isolation(st).is_none() && !st.selection.guides.is_empty() { Ok(()) } else { has_selected_member(s) } +} /// Whether `id` lies inside the isolated object `root` (the root itself does not). pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool { id != root && d.ancestry(id).is_some_and(|a| a.contains(&root)) @@ -506,8 +512,13 @@ fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result /// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error, /// never the selection. `None` when it gives neither (or gives them as `null`). pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result>> { + given_nodes(s, p, command, false) +} +/// The nodes `p` gives `command`, as [`given_objects`] reads them; ruler guide nodes are taken +/// too when `guides`, else refused. +pub(crate) fn given_nodes(s: &Session, p: &Value, command: &str, guides: bool) -> Result>> { if p.get("ids").is_some_and(|v| !v.is_null()) { - return checked_ids_param(s, p, "ids", command).map(Some); + return checked_nodes_param(s, p, "ids", command, guides).map(Some); } match p.get("id").filter(|v| !v.is_null()) { None => Ok(None), @@ -515,7 +526,7 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result { Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) } - Some(v) => Ok(Some(vec![checked_id(s, v, command)?])), + Some(v) => Ok(Some(vec![checked_node(s, v, command, guides)?])), } } /// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form: @@ -541,9 +552,18 @@ pub(crate) fn index(v: u64) -> usize { } /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { + checked_nodes_param(s, p, key, command, false) +} +/// The `key` list as [`checked_ids_param`] checks it; ruler guide nodes are taken too when +/// `guides`, else refused. +pub(crate) fn checked_nodes_param(s: &Session, p: &Value, key: &str, command: &str, guides: bool) -> Result> { let values = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; - values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect() + values + .iter() + .enumerate() + .map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_node(s, value, command, guides))) + .collect() } /// The objects a command acts on: `ids` when given (every one checked, and those inside another @@ -557,11 +577,16 @@ pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> } pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { + checked_node(s, value, command, false) +} +/// `value` as the id of a node of the active document; a ruler guide's is taken only when +/// `guides`. +pub(crate) fn checked_node(s: &Session, value: &Value, command: &str, guides: bool) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; match s.doc()?.doc.node(id) { None => Err(bad(command, format!("no such object id {value}"))), // The guide commands take ruler guides by index. - Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), + Some(n) if n.is_ruler_guide() && !guides => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), Some(_) => Ok(id), } } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index b20eb0d59b..1184687366 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -341,6 +341,33 @@ fn fix_all_is_one_undo_step() { assert_eq!(s.doc().unwrap().doc.artboards.len(), doc.artboards.len()); } +#[test] +fn a_long_straight_run_loses_its_inner_anchors_in_one_step() { + let mut s = session(); + let n: u32 = 5_000; + let mut pts: Vec<(f64, f64)> = (0..n).map(|i| (10.0 + f64::from(i) * 0.05, 150.0)).collect(); + pts.push((260.0, 190.0)); + let id = line(&mut s, &pts); + let other = rect(&mut s, 300.0, 20.0, 40.0, 40.0); + select(&mut s, &[other]); + let check = s.execute("addon.cleanup.check", &json!({"kinds": ["collinearAnchors"], "scope": "document"})).unwrap(); + assert_eq!(check["total"], n - 2, "{check}"); + assert_eq!(check["findings"][0]["ids"], json!([id.0])); + let shown = s.execute("addon.cleanup.show", &json!({"kind": "collinearAnchors", "scope": "document"})).unwrap(); + assert_eq!(shown["count"], n - 2); + assert_eq!(s.doc().unwrap().selection.objects, [id], "Show selects the path"); + select(&mut s, &[other]); + let (steps, before) = (undo_steps(&s), node(&s, id).unwrap()); + let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["collinearAnchors"], "scope": "document"})).unwrap(); + assert_eq!((r["total"].as_u64(), r["fixed"]["collinearAnchors"].as_u64()), (Some(u64::from(n) - 2), Some(u64::from(n) - 2))); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.doc().unwrap().selection.objects, [other], "Fix keeps the selection"); + let ends: Vec<(f64, f64)> = node(&s, id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect(); + assert_eq!(ends, [(10.0, 150.0), (10.0 + f64::from(n - 1) * 0.05, 150.0), (260.0, 190.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).unwrap(), before); +} + #[test] fn the_scope_is_the_selection_or_the_document() { let mut s = session(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index afe86769db..79a203c421 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -19,7 +19,10 @@ //! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the //! image as its Contents row says; new ones go right below that row. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; @@ -45,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -81,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -90,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -144,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -416,9 +419,87 @@ pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) } } -/// [`appearance_targets`] of `cmd`, which needs at least one. -fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { +/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside +/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: +/// the Appearance panel lists those rows and edits them by index. +pub(crate) fn rowless_series_marks(d: &Document, ids: &[NodeId]) -> Vec { + super::graph::series_group_marks_where(d, ids, |group| group.appearance.items.is_empty()) +} + +/// Whether series group `group` holds nothing of its own, as the graph draws it: no fill or stroke row, no effect, the +/// default opacity, blend mode, isolation and knockout, and no graphic style link. +fn holds_no_look(group: &Node) -> bool { + let ap = &group.appearance; + ap.items.is_empty() + && ap.effects.is_empty() + && group.opacity == 1.0 + && group.blend == BlendMode::Normal + && !group.isolate + && group.knockout == Default::default() + && !group.knockout_shape + && group.extras().graphic_style.is_none() +} + +/// The objects whose appearance a command reads for `id`: a graph series group that holds nothing of its own +/// ([`holds_no_look`]) gives the paths directly inside it, backmost first. A series group with an effect, an opacity or +/// another attribute of its own is read itself, with that attribute. +fn look_holders(d: &Document, id: NodeId) -> Vec { + super::graph::series_group_marks_where(d, &[id], holds_no_look) +} + +/// The object whose appearance a command reads for `n`: a graph series group that holds nothing of its own stands for +/// the topmost path directly inside it ([`look_holders`]). +pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { + look_holders(d, n.id).last().and_then(|id| d.node(*id)).unwrap_or(n) +} + +/// Objects whose fill and stroke rows a command edits: the [`appearance_targets`], with a graph series group standing +/// for the paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. +pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { let ids = appearance_targets(s, p)?; + Ok(rowless_series_marks(&s.doc()?.doc, &ids)) +} + +/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, +/// which can hold effects and a graphic style link of its own. +pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { + let own = appearance_targets(s, p)?; + let mut ids = rowless_series_marks(&s.doc()?.doc, &own); + let groups = stood_for(&own, &ids); + ids.extend(groups); + Ok(ids) +} + +/// The series groups among `own` that `ids` ([`rowless_series_marks`] of `own`) replaced with their paths. +pub(crate) fn stood_for(own: &[NodeId], ids: &[NodeId]) -> Vec { + let kept: HashSet = ids.iter().copied().collect(); + own.iter().copied().filter(|id| !kept.contains(id)).collect() +} + +/// Clear Appearance on each series group a command gave a whole new appearance through its paths, so the group's own +/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it. +pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { + for id in groups { + if let Some(n) = d.node_mut(*id) { + clear(n); + (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + n.extras_mut().graphic_style = None; + } + } +} + +/// [`whole_targets`] of `cmd`, which needs at least one. +fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = whole_targets(s, p)?; + if ids.is_empty() { + return Err(bad(cmd, "select objects or give ids")); + } + Ok(ids) +} + +/// [`row_targets`] of `cmd`, which needs at least one. +fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = row_targets(s, p)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -616,7 +697,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } fn clear_appearance(s: &mut Session, p: &Value) -> Result { - let ids = require_targets(s, p, "appearance.clear")?; + let ids = require_whole_targets(s, p, "appearance.clear")?; // When every object is cleared already, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Clear Appearance", |d| { @@ -732,35 +813,64 @@ fn transfer(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`source` and `target` are the same object")); } let copy = switch_or(p, "copy", false, C)?; + // A graph series group stands for the paths directly inside it: as the source, the topmost of them is read and a + // move clears them all; as the target, each of them other than a source takes the appearance. + let sources = look_holders(&s.doc()?.doc, source); + let read = sources.last().copied().unwrap_or(source); + let from: Vec = sources.iter().copied().chain(stood_for(&[source], &sources)).collect(); + let marks = rowless_series_marks(&s.doc()?.doc, &[target]); + let stands = !marks.contains(&target); + let within: HashSet = from.iter().copied().collect(); + let mut targets: Vec = marks.iter().copied().filter(|id| !within.contains(id)).collect(); + if targets.is_empty() { + if stands { + return Err(bad(C, "`source` is the only path in series group `target`")); + } + targets.push(target); + } + // A source that is one of the target group's paths keeps its look: the group stands for it too. + let part: HashSet = if stands { marks.into_iter().collect() } else { HashSet::new() }; + let from: Vec = stood_for(&from, &targets); + let cleared: Vec = if copy { vec![] } else { from.iter().copied().filter(|id| !part.contains(id)).collect() }; + // The group its paths stand for loses its own opacity, blend mode and effects, which would stay on top of them. + let group: Vec = if stands { vec![target] } else { vec![] }; // When both objects come out as they were, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| { - let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?; - let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?; - let mut ap = src.appearance.clone(); - if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) { - ap.rebase_gradients(f, t); + d.node(source).ok_or(EngineError::NoNode(source))?; + let src = d.node(read).cloned().ok_or(EngineError::NoNode(read))?; + for id in &targets { + let to = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + let mut ap = src.appearance.clone(); + if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) { + ap.rebase_gradients(f, t); + } + // Only groups, layers, type and images have a Contents (Characters) row to keep a slot for. + if to.contents_label().is_none() { + ap.contents_index = None; + } + to.appearance = ap; + (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = + (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); + super::object::snap_if_aligned(to); } - // Only groups, layers, type and images have a Contents (Characters) row to keep a slot for. - if to.contents_label().is_none() { - ap.contents_index = None; + clear_stood_for(d, &group); + for id in &cleared { + if let Some(n) = d.node_mut(*id) { + clear(n); + (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); + super::object::snap_if_aligned(n); + } } - to.appearance = ap; - (to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape); - super::object::snap_if_aligned(to); - if !copy && let Some(n) = d.node_mut(source) { - clear(n); - (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); - super::object::snap_if_aligned(n); - } - let changed = if copy { vec![target] } else { vec![source, target] }; + let changed: Vec = cleared.iter().chain(&targets).copied().collect(); let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); - // The target last: on marks of one series, its paint is the one the series keeps. + // The targets last: on marks of one series, their paint is the one the series keeps. let mut rows = top_paint_changed(&before, d, &changed); - rows.extend(painted_rows(d, both_kinds(&[target]))); + rows.extend(painted_rows(d, both_kinds(&targets))); let painted = super::graph::capture_series_top_paints(d, &rows); let object = super::graph::capture_series_object_opacity(d, &changed); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object)) + let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect(); + Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -772,7 +882,7 @@ fn is_group(n: &Node) -> bool { } fn reduce_basic(s: &mut Session, p: &Value) -> Result { - let ids = require_targets(s, p, "appearance.reduceToBasic")?; + let ids = require_whole_targets(s, p, "appearance.reduceToBasic")?; // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); // The Appearance panel's active row (a row of the first selected object's stack, and in each @@ -828,7 +938,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["weight", "opacity"], "appearance.setItem")?; check_switches(p, &["visible"], "appearance.setItem")?; let paint = paint_from(s, p)?; - let ids = appearance_targets(s, p)?; + let ids = row_targets(s, p)?; if ids.is_empty() { return Err(bad("appearance.setItem", "select objects or give ids")); } @@ -1039,7 +1149,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { /// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects. fn show_all_hidden(s: &mut Session, p: &Value) -> Result { - let roots = require_targets(s, p, "appearance.showAllHidden")?; + let roots = require_whole_targets(s, p, "appearance.showAllHidden")?; let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { let shown: Vec = roots @@ -1069,11 +1179,16 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.moveItem"; let to = index_param(p, "to", C)?; - let ids = require_targets(s, p, C)?; let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { + // A graph series group's own Contents row, not its paths, which have none. + let ids = appearance_targets(s, p)?; + if ids.is_empty() { + return Err(bad(C, "select objects or give ids")); + } return move_contents(s, &ids, to); } + let ids = require_targets(s, p, C)?; // Where an aligned row would go in each of several different stacks isn't one place. if aligned && appearances_differ(&s.doc()?.doc, &ids) { return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); 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..b2b44a898d 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::{ @@ -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…", @@ -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]| { @@ -534,6 +561,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| { 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..a9fb07bdf8 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. @@ -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/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index ae3fc521f0..ae48ba02e6 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -210,7 +210,7 @@ pub fn specs() -> Vec { "Guides", [], None, - "{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides", + "{} → [{index, id (its node, which `layer.setProps` hides or locks on its own), vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), hidden, locked (its own visibility and lock), shown, editable (it and its layer shown and unlocked)}…] the ruler guides", has_doc, guide_list ), @@ -219,7 +219,7 @@ pub fn specs() -> Vec { "Select Guides", [], None, - "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", + "{indexes: [index…] (at most 10,000), toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", guides_unlocked, guide_select ), @@ -228,7 +228,7 @@ pub fn specs() -> Vec { "Remove Guide", [], None, - "{index?} delete ruler guide `index`, else the selected ones (as Delete / edit.clear does) → {count}", + "{index?, indexes?: [index…] (at most 10,000)} delete ruler guide `index` (or guides `indexes`, as dragging their rows onto the Layers panel's trash does), else the selected ones (as Delete / edit.clear does), in one undo step; give `index` or `indexes`, not both, and an index given twice counts once; a hidden or locked guide, or one on a hidden or locked layer, is an error → {count}", guides_unlocked, guide_remove ), @@ -246,7 +246,7 @@ pub fn specs() -> Vec { "Move Guide to Layer", [], None, - "{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}", + "{index?, indexes?: [index…] (at most 10,000), layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does; give `index` or `indexes`, not both, and an index given twice counts once → {count}", guides_unlocked, guide_set_layer ), @@ -805,12 +805,17 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { let row = |(i, g): (usize, &Guide)| { let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id)); let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected }); + if let Some(id) = g.id { + r["id"] = json!(id.0); + } if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } if let Some(l) = g.layer { r["layer"] = json!(l.0); } + r["hidden"] = json!(g.hidden); + r["locked"] = json!(g.locked); r["shown"] = json!(st.doc.guide_shown(g)); r["editable"] = json!(st.doc.guide_editable(g)); r @@ -824,27 +829,44 @@ fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result(v: &'a Value, cmd: &str) -> Result<&'a Vec> { + let list = v.as_array().ok_or_else(|| bad(cmd, "indexes must be an array of guide indexes"))?; + if list.len() > MAX_GUIDE_INDEXES { + return Err(bad(cmd, format!("indexes may hold at most {MAX_GUIDE_INDEXES} entries"))); + } + Ok(list) +} + /// The id of ruler guide `index` of the active document (`index` validated). fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd) } -/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer? +/// Is ruler guide `id` one that can be moved or deleted: not hidden or locked itself, nor on a +/// hidden or locked layer? fn check_editable_guide(s: &Session, id: NodeId) -> Result { let d = &s.doc()?.doc; - if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) { + let guide = d.ruler_guide(id); + if guide.as_ref().is_some_and(|g| g.hidden || g.locked) { + return Err(EngineError::Other("the guide is hidden or locked".into())); + } + if !guide.is_some_and(|g| d.guide_editable(&g)) { return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); } Ok(id) } -/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. +/// [`guide_index`] of a guide that can be moved or deleted ([`check_editable_guide`]). fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { check_editable_guide(s, guide_index(s, v, cmd)?) } fn guide_select(s: &mut Session, p: &Value) -> Result { - let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; + let list = guide_index_list(p.get("indexes").ok_or_else(|| bad("guide.select", "missing indexes"))?, "guide.select")?; let ids = guide_ids(&s.doc()?.doc); let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::>>()?; if switch_or(p, "toggle", false, "guide.select")? { @@ -861,18 +883,30 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) })) } -/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. +/// Delete ruler guide `index` (or guides `indexes`), else the selected guides, in one undo step +/// → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { - let gone: Vec = match p.get("index") { - Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], - None => s.doc()?.selection.guides.clone(), + const C: &str = "guide.remove"; + let mut gone: Vec = match (p.get("index"), p.get("indexes")) { + (Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")), + (Some(v), None) => vec![editable_guide_index(s, Some(v), C)?], + (None, Some(list)) => { + let list = guide_index_list(list, C)?; + let ids = guide_ids(&s.doc()?.doc); + // A set, so an index given twice deletes its guide once and the ids held never outnumber the guides. + let set = list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()?; + set.into_iter().collect() + } + (None, None) => s.doc()?.selection.guides.clone(), }; + gone.sort_unstable(); + gone.dedup(); if gone.is_empty() { - return Err(bad("guide.remove", "no guide selected")); + return Err(bad(C, "no guide selected")); } let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" }; s.edit(label, |d, sel| { - d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.binary_search(&id).is_ok())); Ok(()) })?; Ok(json!({ "count": gone.len() })) @@ -888,14 +922,19 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result { if super::layer::locked_within(d, layer) { return Err(EngineError::Other("the layer or group is locked".into())); } - let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { - (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], + let mut moving: Vec = match (p.get("index"), p.get("indexes")) { + (Some(_), Some(_)) => return Err(bad(C, "give index or indexes, not both")), + (Some(v), None) => vec![editable_guide_index(s, Some(v), C)?], (None, Some(list)) => { + let list = guide_index_list(list, C)?; let ids = guide_ids(&s.doc()?.doc); - list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? + list.iter().map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? } (None, None) => s.doc()?.selection.guides.clone(), }; + // An index given twice moves (and counts) its guide once; the others keep their order. + let mut seen = HashSet::new(); + moving.retain(|id| seen.insert(*id)); if moving.is_empty() { return Err(bad(C, "no guide selected (or give an index)")); } diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 8946aa4351..400e13baab 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { "Pencil", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", has_doc, freehand ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index f13b0a0ba4..31e184e195 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -151,7 +151,11 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } let pen = PenRecord::of_samples(&samples); let mut pts: Vec = samples.into_iter().map(|s| s.pt).collect(); - let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, 100.0); + let tol = number_or(p, "fidelity", 1.5, C)?; + let (lo, hi) = (vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, vectorcraft_tools::draw2::MAX_FIT_TOLERANCE); + if !(lo..=hi).contains(&tol) { + return Err(bad(C, format!("`fidelity` must be from {lo} to {hi} pt"))); + } // The tolerance the stroke is fitted at; with `fit: false` it is not fitted. let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); let closed = switch_or(p, "closed", false, C)?; @@ -200,7 +204,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } None => None, }; - let close_tol = (tol * 4.0).max(6.0); + // An extending stroke that ends within four Fidelities of the path's other end closes it: + // at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. + let close_tol = (tol * 4.0).clamp(6.0, 400.0); let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); if closing && let Some(l) = pts.last_mut() { *l = first; diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 3365a1b764..3f95ae0518 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -1,5 +1,5 @@ //! Window › 3D and Materials › Materials › Graphics: art kept as a document symbol (Add as Single -//! Graphic) and mapped onto the planar faces of 3D objects (Extrude & Bevel's caps, a plane). The +//! Graphic) and mapped onto 3D objects (Extrude & Bevel's caps, a plane, Revolve's side). The //! graphics an object shows are the `graphics` parameter of its 3D effect, which `effect.setParams` //! also edits. @@ -28,7 +28,7 @@ pub(super) fn specs() -> Vec { "Map Graphic", [], None, - "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane), scale?: % (1..10000; default 100, or less so the graphic fits inside the object), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it; every object's 3D effect must be Extrude & Bevel (on a solid cap) or Rotate (the Plane type); one undo step → {ids, graphic}", + "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind; on Revolve the front is its swept side, which is the only face), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane; on Revolve from the middle of the side unrolled flat, x around the axis and y along the profile), scale?: % (1..10000; default 100, or less so the graphic fits inside the object, on Revolve inside the half of the side seen from the front), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it (on Revolve wrapped around the side, and drawn only where its outside faces the viewer); every object's 3D effect must be Extrude & Bevel (on a solid cap), Rotate (the Plane type) or Revolve; one undo step → {ids, graphic}", has_doc, map_graphic ), @@ -111,7 +111,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); }; if !effects::takes_graphics(&effect.id) { - return Err(bad(C, format!("object {}: Graphics map onto planar faces: use the Extrude or Plane object type", id.0))); + return Err(bad(C, format!("object {}: {}", id.0, effects::GRAPHICS_NOT_TAKEN))); } // A hollow Extrude & Bevel keeps the graphics it has, to show when Cap is turned on again, // but has no cap to show a new one on. diff --git a/crates/engine/src/cmd/effectcmd/graphics_tests.rs b/crates/engine/src/cmd/effectcmd/graphics_tests.rs index 125ba82514..a9d3a754ce 100644 --- a/crates/engine/src/cmd/effectcmd/graphics_tests.rs +++ b/crates/engine/src/cmd/effectcmd/graphics_tests.rs @@ -68,12 +68,37 @@ fn a_large_graphic_starts_inside_its_object() { assert!((scale - 100.0 * 100.0 / 401.0).abs() < 1e-9, "{scale}"); } +#[test] +fn map_graphic_puts_a_graphic_on_the_side_of_a_revolve() { + let (mut s, id) = setup("threeD.revolve"); + let plain = vectorcraft_render::effects::revolve_art_in(Some(&s.doc().unwrap().doc), s.doc().unwrap().doc.node(id).unwrap()); + let history = undo_len(&s); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap(); + assert_eq!(graphics(&s, id), json!([{"symbol": "Mark", "face": "front", "x": 0.0, "y": 0.0, "scale": 100.0, "rotation": 0.0}])); + assert_eq!(undo_len(&s), history + 1); + let d = &s.doc().unwrap().doc; + assert_ne!(vectorcraft_render::effects::revolve_art_in(Some(d), d.node(id).unwrap()), plain, "the graphic is drawn"); + // A large graphic starts inside the half of the side seen from the front: 100π around the + // axis (the square's far edge is 100 from it) and 300 along the profile (its edge on the axis + // sweeps nothing). + let big = s.execute("path.create", &json!({"d":"M0 0L400 0L400 200L0 200Z"})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Big", "ids": [big]})).unwrap(); + s.execute("threeD.mapGraphic", &json!({"symbol": "Big", "ids": [id.0]})).unwrap(); + let scale = graphics(&s, id)[1]["scale"].as_f64().unwrap(); + assert!((scale - 100.0 * 100.0 * std::f64::consts::PI / 401.0).abs() < 1e-6, "{scale}"); + // The side is its only face. + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "face": "back", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("side"), "{e}"); + assert_eq!(undo_len(&s), history); +} + #[test] fn map_graphic_refuses_what_cannot_take_it_and_changes_nothing() { let (mut s, id) = setup("threeD.inflate"); let history = undo_len(&s); let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap_err().to_string(); - assert!(e.contains("Extrude or Plane"), "{e}"); + assert!(e.contains("Plane, Extrude and Revolve"), "{e}"); let (mut s, id) = setup("threeD.rotate"); for (p, says) in [ (json!({"symbol": "Gone"}), "no symbol named"), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 0f55386830..d4a6c60802 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -18,8 +18,8 @@ use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::live::GradientMesh; use vectorcraft_doc::pattern::{PatternDef, place_art}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; -use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling, TextObject}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, SubPath, Vec2, shapes}; use super::dimension::EndedLink; use super::edit::selected_roots; @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Expand Options", [], None, - "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", + "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", has_selection, |s, _| { let roots = selected_roots(s)?; @@ -197,7 +197,7 @@ impl Expandable { // Once one is found, the next path is not measured. e.gradient = e.gradient || match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), + NodeKind::Text(t) => text_gradient_expands(d, t), _ => shape && gradient_expands(d, c), }; e.fill |= e.gradient; @@ -247,6 +247,34 @@ fn gradient_expands(d: &Document, l: &Node) -> bool { any } +/// Whether type `t` has a gradient fill that Fill expands once Object has outlined it: one of the +/// paths `type.createOutlines` makes of its glyphs, ruby glyphs and underline and strikethrough +/// bars, each painted as its run, has one ([`gradient_expands`] on that path's own box). Inline +/// graphics and color glyphs become other art. Leaves the document as it is and stops at the +/// first such path. +fn text_gradient_expands(d: &Document, t: &TextObject) -> bool { + if !t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))) { + return false; + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let expands = |run: usize, outline: &BezPath| { + let style = t.runs.get(run).map(|r| &r.style); + if !matches!(style.map_or_else(|| t.first_style().fill, |s| s.fill.clone()), Paint::Gradient(_)) { + return false; + } + let path = PathData::from_bezpath(outline).transformed(t.xf); + if path.is_empty() { + return false; + } + let appearance = style.map_or_else(|| t.first_style().appearance(), |s| s.appearance()); + gradient_expands(d, &Node::path(NodeId(u64::MAX), path, appearance)) + }; + let inline = |gi: usize| lay.inlines.iter().find(|i| i.glyph == gi).is_some_and(|i| t.runs.get(i.run).is_some_and(|r| r.inline.is_some())); + lay.glyphs.iter().enumerate().any(|(gi, g)| !inline(gi) && g.image.is_none() && g.colour.is_none() && expands(g.run, &g.outline)) + || lay.ruby.iter().any(|g| expands(g.run, &g.outline)) + || vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar)) +} + /// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]). fn piece_gradient_expands(d: &Document, l: &Node) -> bool { if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) { @@ -606,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) @@ -634,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)) } @@ -1360,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/load.rs b/crates/engine/src/cmd/fileio/load.rs index 4df490af13..bb9dc3711a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -870,7 +870,10 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. /// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`vectorcraft_doc::cmyk::exif_orientation`]) turns or flips it is /// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG -/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). +/// or TIFF keeps its inks, at the 8 or 16 bits the file has, and its CMYK ICC profile as a CMYK +/// TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). An image the decoder reads and that is stored as +/// PNG keeps its gray or RGB samples at 8 or 16 bits, and the gray or RGB ICC profile they are in; +/// other samples become 8-bit RGBA. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -933,35 +936,60 @@ pub fn raster_image(bytes: &[u8]) -> Result { let cms = vectorcraft_color::cms::active(); vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; + // The ICC profile of the samples the image decoder reads; the other readers give sRGB pixels. + let mut icc = None; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let decoder = reader.into_decoder().map_err(err)?; + let mut decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } + // A profile the decoder can't read leaves the image without one. The image decoder + // reads a TIFF's tags within the size of its pixels, so a small TIFF's profile is + // read by the TIFF decoder itself. + icc = match f.id { + "tiff" => vectorcraft_doc::cmyk::tiff_icc_profile(bytes), + _ => decoder.icc_profile().ok().flatten(), + }; image::DynamicImage::from_decoder(decoder).map_err(err)? } }, }; img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms)); - let mut img = img.into_rgba8(); if f.id == "tga" { + let mut img = img.into_rgba8(); super::tgaread::fix_alpha(bytes, &mut img); + return as_png(img, None, shown_ppi); } - as_png(img, None, shown_ppi) + use image::DynamicImage as D; + let img = match img { + D::ImageLuma8(_) | D::ImageLumaA8(_) | D::ImageRgb8(_) | D::ImageRgba8(_) => img, + D::ImageLuma16(_) | D::ImageLumaA16(_) | D::ImageRgb16(_) | D::ImageRgba16(_) => img, + other => D::ImageRgba8(other.into_rgba8()), + }; + let icc = icc.filter(|icc| png_takes_icc(&img, icc)); + as_png(img, icc, shown_ppi) +} + +/// Can a PNG of `img`'s samples carry ICC profile `icc`? Its color space must be the samples': +/// `GRAY` for gray samples, `RGB ` for RGB ones. +fn png_takes_icc(img: &image::DynamicImage, icc: &[u8]) -> bool { + let want = if img.color().has_color() { *b"RGB " } else { *b"GRAY" }; + vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want) } /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its -/// colors are in. -pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { +/// colors are in. `img` has samples a PNG holds: 8 or 16 bits of gray or RGB, with or without alpha. +pub(super) fn as_png(img: impl Into, icc: Option>, ppi: Option<(f64, f64)>) -> Result { use image::ImageEncoder as _; - let size = img.dimensions(); + let img: image::DynamicImage = img.into(); + let size = (img.width(), img.height()); let mut png = Vec::new(); let mut encoder = image::codecs::png::PngEncoder::new(&mut png); if let Some(icc) = icc { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index acc22c7876..ec7d43472d 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -98,8 +98,8 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ FormatOption { name: "longPagesInPoints", ty: "boolean", - default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", + default: "true", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit); false writes it with a UserUnit from PDF 1.6 on", }, FormatOption { name: "createLayers", @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 777c7732a7..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) { @@ -730,6 +736,10 @@ fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bo if o.get("standard").is_none_or(Value::is_null) { o.entry("createLayers").or_insert(json!(true)); } + // A page longer than 14,400 pt keeps its `UserUnit` (PDF export writes it in points). + if o.get("longPagesInPoints").is_none_or(Value::is_null) { + o.insert("longPagesInPoints".into(), json!(false)); + } if let Some(c) = o.get("compress").and_then(Value::as_bool) { let compression = o.entry("compression").or_insert_with(|| json!({})); if let Some(m) = compression.as_object_mut() { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 18bb4853b1..71c1bd06e5 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -1171,6 +1171,216 @@ fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { } } +/// A made-up ICC profile of `len` bytes (at least a header's 128) whose header names the color +/// space `space`; the bytes after the header differ from place to place. +fn icc_profile(space: &[u8; 4], len: usize) -> Vec { + let mut icc: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + icc[..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(space); + icc[36..40].copy_from_slice(b"acsp"); + icc +} + +/// `tiff` (little-endian) with an ICCProfile tag (34675, of type UNDEFINED) holding `icc`: its first +/// directory copied to the end of the file with the tag added in order, as [`tiff_oriented`] adds +/// its tag. +fn tiff_with_icc(tiff: &[u8], icc: &[u8]) -> Vec { + let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap(); + let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]])); + let mut entries: Vec> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect(); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + let data = u32::try_from(out.len()).unwrap(); + out.extend(icc); + if out.len() % 2 == 1 { + out.push(0); + } + let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 34675).unwrap_or(n); + let count = u32::try_from(icc.len()).unwrap(); + entries.insert(i, [&34675u16.to_le_bytes()[..], &7u16.to_le_bytes(), &count.to_le_bytes(), &data.to_le_bytes()].concat()); + let ifd = u32::try_from(out.len()).unwrap(); + out[4..8].copy_from_slice(&ifd.to_le_bytes()); + out.extend(u16::try_from(n + 1).unwrap().to_le_bytes()); + out.extend(entries.concat()); + out.extend([0; 4]); + out +} + +/// The ICC profile and pixels of a stored image, read by the image decoder for its format. +fn stored_pixels(what: &str, img: &RasterImage) -> (Option>, image::ExtendedColorType, image::DynamicImage) { + use image::ImageDecoder as _; + let bytes = Cursor::new(img.blob.bytes.as_slice()); + let mut d: Box = match img.blob.mime.as_str() { + "image/png" => Box::new(image::codecs::png::PngDecoder::new(bytes).unwrap()), + "image/tiff" => Box::new(image::codecs::tiff::TiffDecoder::new(bytes).unwrap()), + other => panic!("{what}: stored as {other}"), + }; + let icc = d.icc_profile().unwrap(); + let color = d.original_color_type(); + (icc, color, image::DynamicImage::from_decoder(d).unwrap()) +} + +/// An image turned by its orientation and stored as PNG keeps the gray or RGB ICC profile its file +/// carries, and its 16-bit samples at 16 bits, from a PNG, a JPEG, a WebP and a TIFF; a TIFF the +/// image decoder reads, stored as PNG without a turn, keeps them too. A profile of another color +/// space than the samples' is left out. +#[test] +fn images_stored_as_png_keep_their_icc_profile_and_16_bit_samples() { + use image::{DynamicImage, ImageEncoder as _}; + let (rgb_icc, gray_icc, cmyk_icc) = (icc_profile(b"RGB ", 600), icc_profile(b"GRAY", 300), icc_profile(b"CMYK", 400)); + // 3 × 2, every sample's low byte different. + let rgb16 = image::ImageBuffer::from_fn(3, 2, |x, y| { + let v = u16::try_from(x * 0x1111 + y * 0x2345).unwrap(); + image::Rgb([0x1000 + v, 0x8001 + v, 0xF0F3 - v]) + }); + let rgb16 = DynamicImage::ImageRgb16(rgb16); + let gray16 = DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(3, 2, |x, y| image::Luma([u16::try_from(x * 0x3001 + y * 0x0107).unwrap()]))); + let rgb8 = DynamicImage::ImageRgb8(image::RgbImage::from_fn(3, 2, |x, y| image::Rgb([u8::try_from(x * 70 + y * 9).unwrap(), 200, 13]))); + let png = |img: &DynamicImage, icc: &[u8], orientation: u16| { + let mut out = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut out); + enc.set_icc_profile(icc.to_vec()).unwrap(); + enc.set_exif_metadata(exif_block(orientation, (150, 150))).unwrap(); + img.write_with_encoder(enc).unwrap(); + out + }; + let webp = { + let mut out = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + enc.set_exif_metadata(exif_block(6, (150, 150))).unwrap(); + rgb8.write_with_encoder(enc).unwrap(); + out + }; + let tiff = |orientation: Option| { + let mut out = Vec::new(); + let mut enc = image::codecs::tiff::TiffEncoder::new(Cursor::new(&mut out)); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + rgb16.write_with_encoder(enc).unwrap(); + match orientation { + Some(o) => tiff_oriented(&out, o, false, None), + None => out, + } + }; + // (what, file, the ICC profile to keep, the pixels as they show, 16 bits) + type Case<'a> = (&'a str, Vec, Option<&'a [u8]>, DynamicImage, bool); + let cases: Vec = vec![ + ("16-bit RGB PNG, orientation 6", png(&rgb16, &rgb_icc, 6), Some(&rgb_icc), rgb16.rotate90(), true), + ("16-bit gray PNG, orientation 8", png(&gray16, &gray_icc, 8), Some(&gray_icc), gray16.rotate270(), true), + ("8-bit RGB PNG, orientation 3", png(&rgb8, &rgb_icc, 3), Some(&rgb_icc), rgb8.rotate180(), false), + ("RGB PNG with a CMYK profile, orientation 6", png(&rgb8, &cmyk_icc, 6), None, rgb8.rotate90(), false), + ("WebP, orientation 6", webp, Some(&rgb_icc), rgb8.rotate90(), false), + ("16-bit RGB TIFF, not turned", tiff(None), Some(&rgb_icc), rgb16.clone(), true), + ("16-bit RGB TIFF, orientation 6", tiff(Some(6)), Some(&rgb_icc), rgb16.rotate90(), true), + ]; + for (what, file, icc, want, wide) in cases { + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (want.width(), want.height(), "image/png"), "{what}"); + let (kept, color, px) = stored_pixels(what, &img); + assert_eq!(kept.as_deref(), icc, "{what}: the ICC profile"); + assert_eq!(color.bits_per_pixel() / u16::from(color.channel_count()), if wide { 16 } else { 8 }, "{what}: the sample depth"); + if wide { + assert_eq!(px.to_rgb16(), want.to_rgb16(), "{what}: the samples"); + } else { + assert_eq!(px.to_rgb8(), want.to_rgb8(), "{what}: the samples"); + } + } + // A JPEG's samples change in encoding; its profile is kept. + let mut jpg = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpg, 95); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + rgb8.write_with_encoder(enc).unwrap(); + let exif = [b"Exif\0\0".as_slice(), &exif_block(6, (150, 150))].concat(); + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let img = raster_image(&turned).unwrap(); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (2, 3, "image/png"), "JPEG"); + assert_eq!(stored_pixels("JPEG", &img).0, Some(rgb_icc.clone()), "JPEG: the ICC profile"); + // Not turned, a PNG keeps its own bytes. + let plain = png(&rgb16, &rgb_icc, 1); + assert_eq!(raster_image(&plain).unwrap().blob.bytes.as_slice(), plain.as_slice(), "a PNG as taken keeps its bytes"); +} + +/// A CMYK TIFF stored again, because it stores one plane per ink or because its orientation turns +/// it, keeps its ICC profile as tag 34675 and its 8 or 16 bits a sample, exactly; one that stores +/// its inks together and isn't turned keeps its own bytes. +#[test] +fn cmyk_tiffs_written_again_keep_their_icc_profile_and_depth() { + use image::DynamicImage; + let icc = icc_profile(b"CMYK", 900); + // 3 × 2, no black, every sample's low byte different. + let ink = |c: usize, p: usize| -> u16 { u16::try_from(0x0101 * (c * 5 + p + 1) + 0x10 * p + c).unwrap() }; + for bits in [8u16, 16] { + let sample = |c: usize, p: usize| match (c, bits) { + (3, _) => 0, + (_, 16) => ink(c, p), + _ => ink(c, p) & 0xFF, + }; + let channels: Vec> = (0..4).map(|c| (0..6).map(|p| sample(c, p)).collect()).collect(); + // The image decoder shows a CMYK pixel without black as 1 - C, 1 - M, 1 - Y, exactly. + let full = if bits == 16 { 0xFFFF } else { 0xFF }; + let shown: Vec = (0..6).flat_map(|p| (0..3).map(|c| full - channels[c][p]).collect::>()).collect(); + let shown = if bits == 16 { + DynamicImage::ImageRgb16(image::ImageBuffer::from_raw(3, 2, shown).unwrap()) + } else { + DynamicImage::ImageRgb8(image::RgbImage::from_raw(3, 2, shown.iter().map(|v| u8::try_from(*v).unwrap()).collect()).unwrap()) + }; + for planar in [false, true] { + let file = tiff_with_icc(&hand_tiff(5, bits, &[], planar, &channels), &icc); + for (orientation, want) in [(None, shown.clone()), (Some(6), shown.rotate90()), (Some(3), shown.rotate180())] { + let what = format!("{bits} bits, planar {planar}, orientation {orientation:?}"); + let file = orientation.map_or_else(|| file.clone(), |o| tiff_oriented(&file, o, false, Some(150))); + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + if orientation.is_none() && !planar { + assert_eq!(img.blob.bytes.as_slice(), file.as_slice(), "{what}: kept as it is"); + } + let (kept, color, px) = stored_pixels(&what, &img); + assert_eq!(kept.as_deref(), Some(icc.as_slice()), "{what}: the ICC profile"); + let depth = if bits == 16 { image::ExtendedColorType::Cmyk16 } else { image::ExtendedColorType::Cmyk8 }; + assert_eq!(color, depth, "{what}: the sample depth"); + assert_eq!(px, want, "{what}: the samples"); + } + } + } +} + +/// A CMYK JPEG turned by its orientation and stored as a CMYK TIFF keeps its whole ICC profile, +/// put together from the two APP2 segments a profile larger than one segment is split into. +#[test] +fn a_turned_cmyk_jpeg_keeps_its_icc_profile_from_every_segment() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + let icc = icc_profile(b"CMYK", 70_000); + let inks: Vec = (0..16u32 * 8).flat_map(|i| [u8::try_from(i).unwrap(), 0, 0, 40]).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 16, 8, 100, None, &o).unwrap(); + let exif = [b"Exif\0\0".as_slice(), &exif_block(6, (300, 300))].concat(); + let mut file = jpg[..2].to_vec(); + file.extend([0xff, 0xe1]); + file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + file.extend(&exif); + let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); + assert_eq!(chunks.len(), 2, "the profile takes two segments"); + for (i, chunk) in chunks.iter().enumerate() { + file.extend([0xff, 0xe2]); + file.extend(u16::try_from(chunk.len() + 16).unwrap().to_be_bytes()); + file.extend(b"ICC_PROFILE\0"); + file.extend([u8::try_from(i + 1).unwrap(), 2]); + file.extend(*chunk); + } + file.extend(&jpg[2..]); + let img = raster_image(&file).unwrap(); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (8, 16, "image/tiff")); + let (kept, color, _) = stored_pixels("CMYK JPEG", &img); + assert_eq!(color, image::ExtendedColorType::Cmyk8); + assert!(kept.as_deref() == Some(icc.as_slice()), "the whole ICC profile: {:?} bytes", kept.map(|k| k.len())); +} + /// With `importer` named, a file up to the importer's limit is read whole and a larger one is an /// error, whatever its content. #[test] 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/gradient.rs b/crates/engine/src/cmd/gradient.rs index 090a7cb46c..18bb3469b9 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", has_doc, edit_gradient ), @@ -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 2c42494160..c45bbf5a67 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -46,7 +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_object_opacity_differs, shown_paint, + capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, 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 229a4d4b42..99a7ec084b 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -79,6 +79,11 @@ struct SeriesRef { index: usize, } +/// How many series groups `spec`'s graph has: a group whose index is past it (the index comes from the file) is not one. +fn series_count(spec: &GraphSpec) -> usize { + spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES) +} + /// Every node in a graph's series groups (the groups too), found in one pass over the document. /// Axes, the legend and groups whose index is past the graph's series (the index comes from the /// file) are left out. @@ -86,7 +91,7 @@ fn series_members(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { let Some(spec) = n.extras().graph.as_deref() else { return }; - let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + let count = series_count(spec); for group in n.children().into_iter().flatten() { let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue }; let r = SeriesRef { graph: n.id, group: group.id, index }; @@ -124,6 +129,38 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { out } +/// `ids` with each graph series group replaced by the paths directly inside it, as [`paint_targets`] reads a series +/// group: the graph draws its series groups again with no rows of their own. A series group with no such path, other +/// ids and the art of a graph design pass through, and the order of `ids` is kept. +pub(crate) fn series_group_marks(doc: &Document, ids: &[NodeId]) -> Vec { + series_group_marks_where(doc, ids, |_| true) +} + +/// [`series_group_marks`] for the series groups `stands` accepts; the others pass through. +pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: impl Fn(&Node) -> bool) -> Vec { + // A series group is a child of a graph with one of the graph's series indexes ([`series_members`]). Each id is + // looked up through its parent, with no pass over the document: the panels call this while they draw. + let is_series_group = |n: &Node| { + let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false }; + let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref()); + graph.is_some_and(|spec| index < series_count(spec)) + }; + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for id in ids { + match doc.node(*id) { + Some(group) if is_series_group(group) && stands(group) => { + let marks: Vec = + group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id).collect(); + if marks.is_empty() { out.push(*id) } else { out.extend(marks) } + } + _ => out.push(*id), + } + } + out.retain(|id| seen.insert(*id)); + out +} + /// Copy the paint just applied to generated series art into that series' fill or stroke. Called /// inside the same document edit, so the visible paint and future regeneration undo together. /// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index fcc2a593c1..e847326e88 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1804,6 +1804,364 @@ fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_st assert_eq!(spec(&s, id).series_paints, was); } +/// Series 0's group, and whether it is as the graph draws it: the group has no rows of its own and each path directly +/// inside it has rows. +fn series_0_group(s: &Session, id: NodeId) -> (NodeId, bool) { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let g = series(&n, 0); + let drawn = g.appearance.items.is_empty() && g.children().unwrap().iter().all(|m| !m.appearance.items.is_empty()); + (g.id, drawn) +} + +/// `appearance.setItem` given a series group's id edits the paths directly inside it, so the graph stores the color and +/// opacity and every mark has them after the graph is drawn again. The group keeps no rows of its own. +#[test] +fn an_appearance_item_set_on_a_series_group_edits_its_marks() { + let (mut s, id, _, marks) = column_marks(); + let (group, drawn) = series_0_group(&s, id); + assert!(drawn); + let was = spec(&s, id).series_paints; + let row = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [group.0], "index": row, "color": "#0000ff", "opacity": 40})).unwrap(); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); + let blue = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(blue.clone()), Some(0.4))); + let (stroke, weight) = { + let m = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + (m.appearance.stroke_paint(), m.appearance.stroke_width()) + }; + series_0_is(&mut s, id, (&blue, 0.4, &stroke, weight), &was); +} + +/// The other Appearance commands given a series group's id, picked with Group Selection or given in `ids`, edit the +/// paths directly inside it, and the graph stores what they change: Add New Fill, Clear Appearance, Move Appearance onto +/// the group, and an opacity set on a row with `transparency.set`. +#[test] +fn appearance_commands_given_a_series_group_edit_its_marks() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + assert!(series_0_group(&s, id).1, "Add New Fill on the selected group"); + let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count(); + assert_eq!(rows, 2, "each mark has a new fill row"); + s.execute("edit.undo", &json!({})).unwrap(); + + let row = top_row(&s, marks[0], true); + s.execute("transparency.set", &json!({"ids": [group.0], "item": row, "opacity": 30})).unwrap(); + assert!(series_0_group(&s, id).1); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.3)); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); + s.execute("edit.undo", &json!({})).unwrap(); + + let (rect, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + s.execute("object.setProps", &json!({"ids": [group.0], "opacity": 50})).unwrap(); + s.execute("appearance.transfer", &json!({"source": rect.0, "target": group.0, "copy": true})).unwrap(); + assert!(series_0_group(&s, id).1); + assert_eq!(s.doc().unwrap().doc.node(group).unwrap().opacity, 1.0, "the appearance replaces the group's own opacity"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// Moving the Contents row of a series group as the graph draws it, with no rows of its own, moves the group's own +/// row: the paths inside it have none. +#[test] +fn moving_a_series_groups_contents_row_moves_its_own_row() { + let (mut s, id, _, _) = column_marks(); + let (group, drawn) = series_0_group(&s, id); + assert!(drawn); + let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": "contents", "to": 0})).unwrap(); + assert_eq!(out["contents"], json!(0)); +} + +/// Add New Stroke, Duplicate Item, Reorder Appearance Item and Remove Item given a series group's id edit the rows of +/// the paths directly inside it, and the group keeps no rows of its own. +#[test] +fn row_commands_given_a_series_group_edit_its_marks_rows() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + let count = |s: &Session, fill: bool| -> Vec { + let d = &s.doc().unwrap().doc; + marks.iter().map(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill() == fill).count()).collect() + }; + let (fills, strokes) = (count(&s, true), count(&s, false)); + s.execute("appearance.addStroke", &json!({"ids": [group.0]})).unwrap(); + assert_eq!(count(&s, false), strokes.iter().map(|n| n + 1).collect::>(), "each mark has a new stroke row"); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.duplicateItem", &json!({"ids": [group.0], "index": fill})).unwrap(); + assert_eq!(count(&s, true), fills.iter().map(|n| n + 1).collect::>(), "each mark has a copy of its fill row"); + let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.len(); + let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": 0, "to": rows - 1})).unwrap(); + assert_eq!(out["index"], json!(rows - 1)); + s.execute("appearance.removeItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true)})).unwrap(); + assert_eq!(count(&s, true), fills); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); +} + +/// Reduce to Basic Appearance given a series group's id reduces the paths directly inside it and drops an effect the +/// group holds. +#[test] +fn reducing_a_series_group_reduces_its_marks_and_drops_its_own_effect() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("appearance.addFill", &json!({"ids": [group.0]})).unwrap(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + s.execute("appearance.reduceToBasic", &json!({"ids": [group.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(group).unwrap().appearance.effects.is_empty(), "the group's effect is dropped"); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count() == 1)); + assert!(series_0_group(&s, id).1); +} + +/// A graphic style added on top (Alt-click) given a series group's id adds its rows to the paths directly inside the +/// group and unlinks a group that held a style link. +#[test] +fn a_graphic_style_added_to_a_series_group_adds_rows_to_its_marks_and_unlinks_the_group() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + blue_style(&mut s, "Blue", false); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id; + s.edit("Link", |d, _| { + d.node_mut(group).unwrap().extras_mut().graphic_style = Some(style); + Ok(()) + }) + .unwrap(); + let fills = |s: &Session, m: NodeId| s.doc().unwrap().doc.node(m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count(); + let was = fills(&s, marks[0]); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0], "add": true})).unwrap(); + assert!(marks.iter().all(|m| fills(&s, *m) == was + 1), "each mark has the style's fill on top"); + assert!(series_0_group(&s, id).1, "the group takes no rows of the style"); + assert_eq!(s.doc().unwrap().doc.node(group).unwrap().extras().graphic_style, None, "the group is unlinked"); +} + +/// Move Appearance from one of a series group's paths onto the group gives the other paths its look and leaves that +/// path as it was. When the path is the only one directly in the group, the command is refused and the group takes no +/// rows. +#[test] +fn moving_an_appearance_from_a_mark_onto_its_own_series_group_keeps_the_mark() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap(); + let green = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": group.0})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "every mark, the source too, is green"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(green)); + + s.edit("One path", |d, _| { + d.children_mut(Some(group)).unwrap().truncate(1); + Ok(()) + }) + .unwrap(); + let only = s.doc().unwrap().doc.node(group).unwrap().children().unwrap()[0].id; + let err = s.execute("appearance.transfer", &json!({"source": only.0, "target": group.0})).unwrap_err(); + assert!(err.to_string().contains("only path"), "{err}"); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); +} + +/// A series group that holds an opacity and an effect of its own is read itself as a source, as any group is: a new +/// graphic style and Move Appearance take the group's opacity and effect, and the group, not a path, is linked. +#[test] +fn a_series_group_with_a_look_of_its_own_is_read_itself_as_a_source() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.execute("transparency.set", &json!({"ids": [group.0], "opacity": 50, "item": null})).unwrap(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap(); + let d = &s.doc().unwrap().doc; + let style = d.graphic_style("Series").unwrap(); + assert_eq!((style.opacity, style.appearance.effects.len()), (0.5, 1)); + assert_eq!(d.node(group).unwrap().extras().graphic_style, Some(style.id)); + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none())); + + let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0, "copy": true})).unwrap(); + let r = s.doc().unwrap().doc.node(rect).unwrap().clone(); + assert_eq!((r.opacity, r.appearance.effects.len()), (0.5, 1)); +} + +/// Clear Appearance and Show All Hidden Attributes given a series group's id also reach an effect the group holds, and +/// Break Link to Graphic Style unlinks the paths a style applied to the group linked. +#[test] +fn whole_appearance_commands_given_a_series_group_edit_the_group_and_its_marks() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let effects = |s: &Session| s.doc().unwrap().doc.node(group).unwrap().appearance.effects.clone(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + s.execute("effect.setParams", &json!({"ids": [group.0], "index": 0, "visible": false, "item": null})).unwrap(); + assert!(!effects(&s)[0].visible); + let shown = s.execute("appearance.showAllHidden", &json!({"ids": [group.0]})).unwrap(); + assert_eq!(shown["ids"], json!([group.0])); + assert!(effects(&s)[0].visible); + s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap(); + assert!(effects(&s).is_empty(), "the group's effect is cleared with its marks' rows"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); + s.execute("edit.undo", &json!({})).unwrap(); + + blue_style(&mut s, "Blue", false); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap(); + assert_eq!(s.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some(("Blue".into(), true))); + s.execute("graphicStyle.breakLink", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none()), "each mark is unlinked"); + assert!(s.selection_graphic_style().is_none()); +} + +/// Redefine Graphic Style on a selected series group whose paths a style links takes the look of the topmost of them. +#[test] +fn redefining_a_graphic_style_from_a_selected_series_group_reads_its_topmost_mark() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + blue_style(&mut s, "Blue", false); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap(); + let top = *marks.last().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [top.0], "index": top_row(&s, top, true), "color": "#00ff00"})).unwrap(); + let green = s.doc().unwrap().doc.node(top).unwrap().appearance.fill_paint(); + s.execute("graphicStyle.redefine", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.graphic_style("Blue").unwrap().appearance.fill_paint(), green); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "the marks still linked follow"); +} + +/// A series group as the graph draws it, read as a source, gives the look of the topmost path directly inside it: New +/// Graphic Style keeps that path's fill and links it, Move Appearance copies it, and a move clears every path of the +/// group. +#[test] +fn a_series_group_read_as_a_source_gives_its_topmost_marks_look() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let top = *marks.last().unwrap(); + s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true), "color": "#0000ff", "opacity": 30})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap(); + let look = s.doc().unwrap().doc.node(top).unwrap().appearance.clone(); + assert_ne!(look.fill_paint(), s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint()); + s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.graphic_style("Series").unwrap().appearance.fill_paint(), look.fill_paint()); + assert_eq!(d.node(top).unwrap().extras().graphic_style, Some(d.graphic_style("Series").unwrap().id)); + + let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.node(rect).unwrap().appearance.fill_paint(), look.fill_paint()); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == Paint::None), "the move clears each mark"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); +} + +/// A series group that has a row of its own, as a document saved earlier can hold, is edited itself: the Appearance +/// panel lists that row and edits it by its index. +#[test] +fn a_series_group_with_a_row_of_its_own_is_edited_itself() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + s.edit("Row", |d, _| { + let g = d.node_mut(group).unwrap(); + g.appearance = vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0).into(); + Ok(()) + }) + .unwrap(); + let was = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone(); + s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, group, true), "color": "#0000ff"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_ne!(d.node(group).unwrap().appearance.fill_paint(), Paint::None); + assert_eq!(d.node(marks[0]).unwrap().appearance, was, "the marks keep their rows"); +} + +/// A graphic style applied to a series group's id styles and links the paths directly inside it, and the graph stores +/// the style's fill and stroke. +#[test] +fn a_graphic_style_applied_to_a_series_group_styles_its_marks() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let (_, blue) = blue_style(&mut s, "Blue", false); + let was = spec(&s, id).series_paints; + let own = json!({"ids": [group.0], "opacity": 50, "blend": "multiply", "isolate": true, "knockout": "on", "item": null}); + s.execute("transparency.set", &own).unwrap(); + s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap(); + let g = s.doc().unwrap().doc.node(group).unwrap().clone(); + assert!(g.isolate && g.knockout != Default::default() && g.blend != vectorcraft_color::BlendMode::Normal); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0]})).unwrap(); + assert!(series_0_group(&s, id).1, "the group takes no rows of the style"); + let g = s.doc().unwrap().doc.node(group).unwrap().clone(); + assert_eq!((g.opacity, g.isolate), (1.0, false), "the style replaces the group's own opacity and isolation"); + assert_eq!((g.blend, g.knockout), (vectorcraft_color::BlendMode::Normal, Default::default()), "and its blend mode and knockout"); + assert!(g.appearance.effects.is_empty(), "and its effects"); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id; + let d = &s.doc().unwrap().doc; + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style)), "each mark is linked to the style"); + assert_eq!(d.node(group).unwrap().extras().graphic_style, None); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None))); + series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was); +} + +/// Redefine Graphic Style on a series group linked to the style (as a file can hold it) restyles the paths directly +/// inside the group and links them, and the graph stores the new look; the group goes back to no rows of its own. +#[test] +fn redefining_a_graphic_style_restyles_the_marks_of_a_linked_series_group() { + let (mut s, id, marks) = stored_red_stroke(); + let (group, _) = series_0_group(&s, id); + let (rect, _) = blue_style(&mut s, "Blue", false); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone(); + s.edit("Link", |d, _| { + let g = d.node_mut(group).unwrap(); + g.appearance = style.appearance.clone().into(); + g.extras_mut().graphic_style = Some(style.id); + Ok(()) + }) + .unwrap(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + let was = spec(&s, id).series_paints; + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + assert!(series_0_group(&s, id).1, "the group has no rows of its own"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.node(group).unwrap().extras().graphic_style, None); + assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id))); + assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green)); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(green.clone()), None, Some(Paint::None))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored"); + s.execute("edit.redo", &json!({})).unwrap(); + series_0_is(&mut s, id, (&green, 1.0, &Paint::None, 0.0), &was); +} + +/// Redefine Graphic Style on a linked series group leaves a path of the group that is linked to the style but no +/// longer has its look as it is, unlinked, and restyles the group's other paths. +#[test] +fn redefining_a_graphic_style_keeps_an_edited_mark_of_a_linked_series_group() { + let (mut s, id, _, marks) = column_marks(); + let (group, _) = series_0_group(&s, id); + let (rect, _) = blue_style(&mut s, "Blue", false); + let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone(); + s.edit("Link", |d, _| { + let g = d.node_mut(group).unwrap(); + g.appearance = style.appearance.clone().into(); + g.extras_mut().graphic_style = Some(style.id); + d.node_mut(marks[0]).unwrap().extras_mut().graphic_style = Some(style.id); + Ok(()) + }) + .unwrap(); + let edited = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone(); + s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap(); + let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint(); + s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap(); + let d = &s.doc().unwrap().doc; + let first = d.node(marks[0]).unwrap(); + assert_eq!((&first.appearance, first.extras().graphic_style), (&edited, None), "the edited mark keeps its look, unlinked"); + assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green)); + assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id))); +} + /// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph. #[test] fn default_fill_and_stroke_on_a_series_mark_is_kept() { @@ -1918,7 +2276,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(); @@ -2332,7 +2690,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)]); @@ -2460,7 +2819,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); @@ -2662,7 +3022,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); @@ -2901,7 +3262,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"])); @@ -3299,7 +3661,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 }; @@ -3821,7 +4184,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(); @@ -3962,7 +4326,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(); @@ -4081,7 +4446,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(); @@ -4208,7 +4574,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)); @@ -4251,7 +4618,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); @@ -4420,7 +4788,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/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 `