diff --git a/Cargo.lock b/Cargo.lock index ce5569cde9..10c93a8322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6477,6 +6477,7 @@ dependencies = [ "vectorcraft-text", "vectorcraft-three-d", "vectorcraft-trace", + "wat", ] [[package]] @@ -6665,6 +6666,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 7bdfece60c..fee2ab4e3b 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -111,15 +111,16 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # 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 b753427d8b..126b671877 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..1daccc22f1 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -1504,6 +1504,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() { 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 c10b72da44..7594bc2344 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -54,6 +54,8 @@ mod mac_tablet; mod mac_window; #[cfg(target_os = "macos")] mod open_documents; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +mod portal_request; #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))] mod portal_save; mod prefs_dir; @@ -88,6 +90,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}; @@ -117,7 +121,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 74ac2d50d5..f4297343a6 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -533,7 +533,7 @@ impl Cms { } } - /// Gamut warning: can this colour be printed in the working CMYK space? + /// Gamut warning: can this color be printed in this context's CMYK space? pub fn out_of_gamut(&self, c: &Color) -> bool { self.gamut_error(c) > GAMUT_THRESHOLD } diff --git a/crates/color/src/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/document.rs b/crates/doc/src/document.rs index 708d0aee03..057a9df6a6 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -286,7 +286,9 @@ impl Document { self.next_id = self.next_id.max(id); } - /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data), then give + /// the objects outside the layer tree (opacity-mask art, symbols, graph designs and patterns) + /// whose id is missing or already taken a new one, so each stored object has an id of its own. pub fn fix_next_id(&mut self) { // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a // counter up there: new ids start under them and never reach them, where counting on from @@ -297,13 +299,12 @@ impl Document { max = max.max(id); } }; - for l in &self.layers { - l.walk(&mut |n| see(n.id.0)); - } + self.visit_stored(|n, _| see(n.id.0)); self.slices.iter().for_each(|s| see(s.id.0)); self.assets.iter().for_each(|a| see(a.id)); let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); self.next_id = counter.max(max.saturating_add(1)); + self.renumber_stored_duplicates(); } /// Find a node anywhere in the tree. diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 4ec7aab1f6..14d0404bfc 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -554,6 +554,13 @@ impl Document { t.cached_baselines.clear(); t.cached_ink = None; } + // Opacity-mask art is stored art too: the copy's gets ids of its own. + if let Some(art) = n.extras().mask.as_ref().map(|m| Arc::clone(&m.art)) { + let art = Arc::new(self.reid(&art)); + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = art; + } + } let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); if let Some(ch) = n.children_mut() { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a1122e5a7c..63a2676f77 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -142,6 +142,7 @@ mod graphic_style; mod guides; mod limits; mod records; +mod stored_art; mod units; pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; @@ -154,6 +155,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; +pub use stored_art::{StoredPlace, StoredRoot}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index cf7a4d2fa0..52453fd5a6 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, }; diff --git a/crates/doc/src/stored_art.rs b/crates/doc/src/stored_art.rs new file mode 100644 index 0000000000..e623e0ed0e --- /dev/null +++ b/crates/doc/src/stored_art.rs @@ -0,0 +1,376 @@ +//! Every object a document stores, wherever it is kept: in the layers, in opacity-mask art, and in +//! the art of symbols, graph designs and pattern swatches. [`Document::node`] finds objects in the +//! layer tree only; the lookups here find the others by their ids too, which +//! [`Document::fix_next_id`] keeps unique across all of them. + +use std::collections::HashSet; +use std::sync::Arc; + +use super::limits::MAX_NEXT_ID; +use super::{Document, Node, NodeId}; + +/// The art a stored object belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StoredRoot { + /// Layer `index` of [`Document::layers`]. + Layer(usize), + /// Symbol `index` of [`Document::symbols`]. + Symbol(usize), + /// Graph design `index` of [`Document::graph_designs`]. + GraphDesign(usize), + /// Object `art` of pattern swatch `pattern` of [`Document::patterns`]. + Pattern { pattern: usize, art: usize }, +} + +/// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs +/// to (the innermost one when masks hold masks). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StoredPlace { + pub root: StoredRoot, + pub mask_of: Option, +} + +impl StoredPlace { + /// Is the object in the layer tree itself, where [`Document::node`] finds it and the canvas + /// draws it as itself? + pub fn in_layers(self) -> bool { + matches!(self.root, StoredRoot::Layer(_)) && self.mask_of.is_none() + } +} + +/// One step down from a node: to its child at an index, or to its opacity-mask art. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Child(usize), + Mask, +} + +/// `n` and everything below it with its place, the opacity-mask art right after the object it +/// masks, then the children. +fn visit<'a>(n: &'a Arc, place: StoredPlace, f: &mut dyn FnMut(&'a Arc, StoredPlace)) { + f(n, place); + if let Some(m) = &n.extras().mask { + visit(&m.art, StoredPlace { mask_of: Some(n.id), ..place }, f); + } + for c in n.children().into_iter().flatten() { + visit(c, place, f); + } +} + +/// The steps from `n` to the first node in [`visit`] order with id `id`, and the object whose mask +/// holds it last on the way. +fn find(n: &Node, id: NodeId, steps: &mut Vec, mask_of: &mut Option) -> bool { + if n.id == id { + return true; + } + if let Some(m) = &n.extras().mask { + steps.push(Step::Mask); + let before = *mask_of; + *mask_of = Some(n.id); + if find(&m.art, id, steps, mask_of) { + return true; + } + *mask_of = before; + steps.pop(); + } + for (i, c) in n.children().into_iter().flatten().enumerate() { + steps.push(Step::Child(i)); + if find(c, id, steps, mask_of) { + return true; + } + steps.pop(); + } + false +} + +/// The node `steps` below `n`. +fn at<'a>(mut n: &'a Arc, steps: &[Step]) -> Option<&'a Arc> { + for s in steps { + n = match s { + Step::Mask => &n.extras().mask.as_ref()?.art, + Step::Child(i) => n.children()?.get(*i)?, + }; + } + Some(n) +} + +/// The node `steps` below `n` to change, the nodes on the way copied when shared. +fn at_mut<'a>(mut n: &'a mut Arc, steps: &[Step]) -> Option<&'a mut Node> { + for s in steps { + let node = Arc::make_mut(n); + n = match s { + Step::Mask => &mut node.extras_if_any()?.mask.as_mut()?.art, + Step::Child(i) => node.children_mut()?.get_mut(*i)?, + }; + } + Some(Arc::make_mut(n)) +} + +/// Gives every node from `n` down that `fresh` names a new id from `next`, counting nodes in +/// [`visit`] order from `*count`; only the nodes on the way to one are copied. Each part below is +/// renumbered through a second handle, so a new pointer marks a change. +fn renumber(n: &mut Arc, fresh: &HashSet, count: &mut usize, next: &mut u64) { + let own = fresh.contains(count); + *count += 1; + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + renumber(&mut art, fresh, count, next); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut slots = vec![]; + // Children's new ids by their old ones, for an Objects on Path group, which names its path and + // members by id. An id another child keeps still names that child. + let mut renamed = std::collections::HashMap::new(); + let mut kept = HashSet::new(); + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut new = Arc::clone(c); + renumber(&mut new, fresh, count, next); + if new.id == c.id { + kept.insert(c.id); + } else { + renamed.insert(c.id, new.id); + } + if !Arc::ptr_eq(&new, c) { + slots.push((i, new)); + } + } + renamed.retain(|old, _| !kept.contains(old)); + if !own && mask.is_none() && slots.is_empty() { + return; + } + let node = Arc::make_mut(n); + if own { + node.id = NodeId(*next); + *next += 1; + } + if let (Some(art), Some(m)) = (mask, node.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(children) = node.children_mut() { + for (i, c) in slots { + if let Some(slot) = children.get_mut(i) { + *slot = c; + } + } + } + if !renamed.is_empty() + && let Some(on_path) = node.extras_if_any().and_then(|e| e.objects_on_path.as_mut()) + { + on_path.rename(&renamed); + } +} + +impl Document { + /// The roots of the art kept outside the layers, each with its [`StoredRoot`]. + fn definition_roots(&self) -> impl Iterator, StoredRoot)> { + let symbols = self.symbols.iter().enumerate().map(|(i, s)| (&s.art, StoredRoot::Symbol(i))); + let designs = self.graph_designs.iter().enumerate().map(|(i, g)| (&g.art, StoredRoot::GraphDesign(i))); + let patterns = self + .patterns + .iter() + .enumerate() + .flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a }))); + symbols.chain(designs).chain(patterns) + } + + /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns. + fn stored_roots(&self) -> impl Iterator, StoredRoot)> { + self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots()) + } + + /// The root `root` names, to change. + fn stored_root_mut(&mut self, root: StoredRoot) -> Option<&mut Arc> { + match root { + StoredRoot::Layer(i) => self.layers.get_mut(i), + StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art), + StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art), + StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art), + } + } + + /// Visit every stored object with its place: the layer tree with the opacity-mask art of its + /// objects, then the art of symbols, graph designs and pattern swatches (their definitions, + /// once each, never the copies instances and tiles draw). + pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) { + self.visit_stored_arcs(|n, place| f(n, place)); + } + + /// [`Self::visit_stored`] with each object's shared handle, which keeps its identity from one + /// look to the next while the object is unchanged (for caches such as thumbnails). + pub fn visit_stored_arcs<'a>(&'a self, mut f: impl FnMut(&'a Arc, StoredPlace)) { + for (root, r) in self.stored_roots() { + visit(root, StoredPlace { root: r, mask_of: None }, &mut f); + } + } + + /// Where object `id` is stored: the root, and the steps from it. + fn stored_path(&self, id: NodeId) -> Option<(StoredPlace, Vec)> { + if let Some(path) = self.index_path(id) { + let (first, rest) = path.split_first()?; + return Some((StoredPlace { root: StoredRoot::Layer(*first), mask_of: None }, rest.iter().map(|i| Step::Child(*i)).collect())); + } + // Not in the layer tree: in mask art (in the layers too) or in a definition's art. + for (root, r) in self.stored_roots() { + let (mut steps, mut mask_of) = (vec![], None); + if find(root, id, &mut steps, &mut mask_of) { + return Some((StoredPlace { root: r, mask_of }, steps)); + } + } + None + } + + /// Stored object `id` (see [`Self::visit_stored`]) with its place: one in the layer tree as + /// [`Self::node`] finds it, else the first in visiting order. + pub fn stored_node(&self, id: NodeId) -> Option<(&Arc, StoredPlace)> { + let (place, steps) = self.stored_path(id)?; + let root = self.stored_roots().find(|(_, r)| *r == place.root)?.0; + Some((at(root, &steps)?, place)) + } + + /// [`Self::stored_node`] to change; the nodes on the way are copied when shared. + pub fn stored_node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + let (place, steps) = self.stored_path(id)?; + at_mut(self.stored_root_mut(place.root)?, &steps) + } + + /// Gives the objects outside the layer tree (in opacity-mask art and in the art of symbols, + /// graph designs and patterns) whose id is 0, past the id range, or already taken by an object + /// visited before them (the layer tree's own objects first) a new id, so every stored object + /// can be named by its id. Returns how many changed; the id counter then stands above them. + pub(crate) fn renumber_stored_duplicates(&mut self) -> usize { + let mut seen: HashSet = HashSet::new(); + for l in &self.layers { + l.walk(&mut |n| { + seen.insert(n.id); + }); + } + self.slices.iter().for_each(|s| { + seen.insert(s.id); + }); + // Per root, the visiting indices of the nodes that take a new id. + let mut fresh: Vec<(StoredRoot, HashSet)> = vec![]; + for (root, r) in self.stored_roots() { + let (mut i, mut here) = (0, HashSet::new()); + visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| { + if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) { + here.insert(i); + } + i += 1; + }); + if !here.is_empty() { + fresh.push((r, here)); + } + } + let total = fresh.iter().map(|(_, f)| f.len()).sum(); + if total == 0 { + return 0; + } + // No id left to give: the objects keep theirs rather than share one given out later. + if self.peek_next_id().saturating_add(total as u64) > MAX_NEXT_ID { + return 0; + } + let mut next = self.peek_next_id(); + for (r, f) in fresh { + if let Some(root) = self.stored_root_mut(r) { + renumber(root, &f, &mut 0, &mut next); + } + } + self.raise_next_id(next); + total + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{GraphDesign, OpacityMask, PatternDef, Symbol}; + use vectorcraft_geom::Rect; + + fn rect(id: u64) -> Node { + Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), crate::Appearance::default_art()) + } + + fn masked(id: u64, art: Node) -> Node { + rect(id).with_mask(Some(Box::new(OpacityMask::new(art, true)))) + } + + /// A document with object 10 in its layer, mask art 11 on it, and one object in a symbol, + /// a graph design and a pattern (ids `defs`). + fn doc(defs: [u64; 3]) -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, masked(10, rect(11))).unwrap(); + d.symbols.push(Symbol { name: "S".into(), art: Arc::new(rect(defs[0])), dynamic: false }); + d.graph_designs.push(GraphDesign { name: "G".into(), art: Arc::new(rect(defs[1])) }); + d.patterns.push(PatternDef::new("P", vec![Arc::new(rect(defs[2]))])); + d.raise_next_id(20); + d + } + + #[test] + fn stored_objects_are_found_in_every_art() { + let d = doc([12, 13, 14]); + let place = |id: u64| d.stored_node(NodeId(id)).map(|(n, p)| (n.id.0, p)); + assert!(place(10).unwrap().1.in_layers()); + assert_eq!(place(11).unwrap().1, StoredPlace { root: StoredRoot::Layer(0), mask_of: Some(NodeId(10)) }); + assert_eq!(place(12).unwrap().1.root, StoredRoot::Symbol(0)); + assert_eq!(place(13).unwrap().1.root, StoredRoot::GraphDesign(0)); + assert_eq!(place(14).unwrap().1.root, StoredRoot::Pattern { pattern: 0, art: 0 }); + assert!(d.node(NodeId(11)).is_none() && d.node(NodeId(12)).is_none(), "the layer lookup stays the layer tree's"); + assert!(d.stored_node(NodeId(99)).is_none()); + let mut seen = vec![]; + d.visit_stored(|n, _| seen.push(n.id.0)); + assert_eq!(seen, [d.layers[0].id.0, 10, 11, 12, 13, 14]); + } + + #[test] + fn stored_objects_change_by_id() { + let mut d = doc([12, 13, 14]); + let before = d.clone(); + d.stored_node_mut(NodeId(13)).unwrap().name = Some("changed".into()); + assert_eq!(d.graph_designs[0].art.name.as_deref(), Some("changed")); + assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art), "other art is not copied"); + assert_eq!(before.graph_designs[0].art.name, None, "the earlier document is unchanged"); + d.stored_node_mut(NodeId(11)).unwrap().name = Some("mask".into()); + assert_eq!(d.stored_node(NodeId(11)).unwrap().0.name.as_deref(), Some("mask")); + } + + #[test] + fn fix_next_id_gives_nested_duplicates_new_ids() { + // The symbol's object repeats the layer's object, the design's has no id, and the pattern's + // repeats the mask art's. + let mut d = doc([10, 0, 11]); + d.next_id = 1; + d.fix_next_id(); + let mut ids = vec![]; + d.visit_stored(|n, _| ids.push(n.id.0)); + let unique: HashSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(!ids.contains(&0)); + assert_eq!(&ids[..3], &[d.layers[0].id.0, 10, 11], "the layer tree and the first mask art keep theirs"); + assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id()); + // Ids already unique are kept, and a second pass changes nothing. + let again = d.clone(); + assert_eq!(d.renumber_stored_duplicates(), 0); + assert_eq!(d, again); + } + + #[test] + fn renumbered_objects_on_path_keep_their_path_and_members() { + // A symbol's Objects on Path group whose path repeats the layer's object 10 and whose + // member has no id. + let mut d = doc([12, 13, 14]); + let (path, member) = (rect(10), rect(0)); + let rec = crate::objects_on_path::ObjectsOnPath::new(path.id, &[&member], crate::objects_on_path::CENTER, 0.0); + let mut g = Node::new(NodeId(15), crate::NodeKind::Group { children: vec![Arc::new(path), Arc::new(member)], clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(rec)); + d.symbols[0].art = Arc::new(g); + d.fix_next_id(); + let art = &d.symbols[0].art; + let kids: Vec = art.children().unwrap().iter().map(|c| c.id).collect(); + assert!(kids.iter().all(|id| id.0 != 10 && id.0 != 0), "{kids:?}"); + let on_path = art.extras().objects_on_path.as_ref().unwrap(); + assert_eq!(on_path.path, kids[0]); + assert_eq!(on_path.members.iter().map(|m| m.id).collect::>(), [kids[1]]); + } +} 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/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/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..89a258bb07 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1901,22 +1901,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 +1932,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) { @@ -2093,7 +2101,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 +2124,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 +2137,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 +2146,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 +2179,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 +2391,134 @@ 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); + 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/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/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/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..ad860b453a 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()) { @@ -1360,8 +1388,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/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ed6ff95808..e867fdee41 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; 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 ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 31539ff51d..0fbffcea82 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -629,6 +629,13 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { { return Ok(Encoded::one_with_notes(bytes, notes)); } + // So does a `.ai` file of the art's bounds (a legacy version 7 `.ai` file has none to leave out). + if use_artboards == Some(false) + && f.id == "ai" + && let Some(enc) = super::save::encode_ai_art_page(C, f, doc, p)? + { + return Ok(enc); + } if use_artboards == Some(false) && let Some((art, q)) = art_page(C, doc, p)? { 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 e1cbf68bf2..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) { @@ -685,6 +691,31 @@ struct AiEditingData { editing_data: Option, } +/// Why a `.ai` file of the art's bounds (`useArtboards: false`) carries no editing data. +pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str = + "Preserve editing was left out: an .ai file of the art's bounds would replace the document's artboards when reopened"; + +/// The error for a `.ai` file of the art's bounds without PDF content. +pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false"; + +/// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of +/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor +/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`]. +/// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy +/// version 7 `.ai` file. +pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + if super::encode::use_artboards(cmd, p)? != Some(false) || super::legacy_ai::wanted(cmd, f, p)? { + return Ok(None); + } + if !bool_or(p, "pdfCompatible", true) { + return Err(bad(cmd, ART_PAGE_NEEDS_PDF)); + } + let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?; + enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string()); + Ok(Some(enc)) +} + /// The shared Save and Export encoder for `.ai` files (and `.ait` templates, the same file): /// PDF-compatible by default, without PDF content (`pdfCompatible: false`), or legacy version 7 /// `.ai` files (`aiVersion: "7"`). @@ -692,13 +723,23 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res if super::legacy_ai::wanted(cmd, f, p)? { return super::legacy_ai::encode(cmd, f, doc, p); } + encode_ai_pages(cmd, f, doc, p, true) +} + +/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and +/// the editing copy for other apps when `editing` is true, and neither otherwise. +fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result { let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { - o.insert("preserveEditing".into(), json!(true)); + o.insert("preserveEditing".into(), json!(editing)); // Keep upstream AI PDF layers, including hidden layers, unless a standard decides. 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 2970238e38..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(); @@ -402,28 +407,76 @@ fn ai_export_validates_options_even_when_editing_data_is_omitted() { } } -#[test] -fn ai_export_without_artboards_keeps_art_outside_the_original_pages() { +/// Two artboards, a visible 30 × 20 pt rectangle outside them at (180, 100) and a hidden one. +fn art_outside_the_artboards() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 2})).unwrap(); s.execute("paint.setStroke", &json!({"none": true})).unwrap(); s.execute("shape.rectangle", &json!({"x": 180, "y": 100, "width": 30, "height": 20})).unwrap(); s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 50, "height": 40})).unwrap(); s.execute("object.hide", &json!({})).unwrap(); + s +} + +/// Whether the `.ai` file `bytes` carries the editing copy of its art for other apps. +fn carries_private_data(bytes: &[u8]) -> bool { + let opts = LoadOptions { native: false, ..Default::default() }; + load::load_with("a.ai", bytes, &opts).unwrap().editing_data +} + +#[test] +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 r = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "range": "2"})).unwrap(); - let bytes = b64(&r); - let pages = vectorcraft_pdf::import(&bytes).unwrap(); - assert_eq!(pages.artboards.len(), 1); - assert_eq!((pages.artboards[0].rect.width(), pages.artboards[0].rect.height()), (30.0, 20.0)); - let editing = vectorcraft_pdf::editing(&bytes).unwrap(); - let exported = vectorcraft_format::load(&editing.data).unwrap(); - assert_eq!(exported.artboards.len(), 1); - assert_eq!(exported.artboards[0].rect, vectorcraft_geom::Rect::new(180.0, 100.0, 210.0, 120.0)); - assert_eq!(comparable(&s.doc().unwrap().doc), before); + let left_out = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w == super::save::AI_EDITING_NEEDS_THE_ARTBOARDS); + 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()); + let r = s.execute("document.export", ¶ms).unwrap(); + let bytes = if params.get("path").is_some() { std::fs::read(&path).unwrap() } else { b64(&r) }; + // One page, the visible art's bounds, whatever range names. + let pages = vectorcraft_pdf::import(&bytes).unwrap(); + assert_eq!(pages.artboards.len(), 1, "{params}"); + assert_eq!((pages.artboards[0].rect.width(), pages.artboards[0].rect.height()), (30.0, 20.0), "{params}"); + assert!(vectorcraft_pdf::editing(&bytes).is_none(), "{params}"); + assert!(!carries_private_data(&bytes), "{params}"); + assert!(left_out(&r), "{params}: {r}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before); + } + // The artboards keep both editing copies, without the warning. + for params in [json!({"format": "ai"}), json!({"format": "ai", "useArtboards": true})] { + let r = s.execute("document.export", ¶ms).unwrap(); + let bytes = b64(&r); + let editing = vectorcraft_pdf::editing(&bytes).expect("editing data"); + assert_eq!(vectorcraft_format::load(&editing.data).unwrap().artboards.len(), 2, "{params}"); + assert!(carries_private_data(&bytes), "{params}"); + assert!(!left_out(&r), "{params}: {r}"); + } assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err()); } +#[test] +fn an_ai_of_the_arts_bounds_without_pdf_is_refused() { + let mut s = art_outside_the_artboards(); + let before = comparable(&s.doc().unwrap().doc); + for extra in [json!({}), json!({"editingData": false}), json!({"editingData": true})] { + for command in ["document.export", "document.serialize"] { + let mut params = json!({"format": "ai", "useArtboards": false, "pdfCompatible": false}); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains(super::save::ART_PAGE_NEEDS_PDF), "{command} {params}: {e}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before); + } + } + // A document with no visible art is still refused for that. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false})).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); +} + /// Color Halftone's Max Radius is in pixels of the raster effects resolution since document /// version 3 (#3260). A PDF saved with Preserve Editing carries the native document, so one from /// before that opens with the radius converted, drawing the same dots; one saved since opens as @@ -623,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. @@ -635,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}"); @@ -765,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]| { @@ -835,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(); @@ -842,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:#?}"); @@ -924,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); @@ -943,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/layer.rs b/crates/engine/src/cmd/layer.rs index f816ec7688..f589ea62da 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -14,8 +14,8 @@ use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor use vectorcraft_geom::{Affine, Rect}; use super::{ - CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, - has_selection, ids_param, index, object_param, ok, str_param, switch_or, + CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, checked_node, checked_nodes_param, cmd, + color_value, f64_or, has_doc, has_selection, ids_param, index, object_param, ok, str_param, switch_or, }; use crate::EngineError; @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Layer Options…", ["Window", "Layers"], None, - "{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name (a hex color with an alpha part, #rrggbbaa or #rgba, is an error)} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step", + "{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name (a hex color with an alpha part, #rrggbbaa or #rgba, is an error)} Layer Options; rows that are not layers take name, visible and locked, and `ids` may name ruler guides (their `guide.list` id), which hide or lock on their own. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step", has_doc, set_props ), @@ -210,11 +210,16 @@ pub fn specs() -> Vec { /// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current /// layer (capped at [`MAX_ROWS`]). pub(crate) fn rows_param(s: &Session, p: &Value, command: &str) -> Result> { + rows_or_guides_param(s, p, command, false) +} + +/// The rows [`rows_param`] reads; given ruler guide ids are taken too when `guides`, else refused. +fn rows_or_guides_param(s: &Session, p: &Value, command: &str, guides: bool) -> Result> { // Presence is explicit intent: validate every target before fallback, truncation or edits. let mut ids = if p.get("ids").is_some() { - checked_ids_param(s, p, "ids", command)? + checked_nodes_param(s, p, "ids", command, guides)? } else if let Some(id) = p.get("id") { - vec![checked_id(s, id, command)?] + vec![checked_node(s, id, command, guides)?] } else { let st = s.doc()?; let rows = st.highlighted_rows(); @@ -637,7 +642,8 @@ fn set_props(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.setProps"; let xml_id = s.prefs.identify_objects_by == "xmlId"; check_options(C, p, xml_id)?; - let ids = rows_param(s, p, C)?; + // A ruler guide's row takes visible and locked: its own eye and lock. + let ids = rows_or_guides_param(s, p, C, true)?; if ids.is_empty() { return Err(bad(C, "no rows")); } diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index f2ce9d4d12..99295e5c79 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::findfiles::{self, Limits, Visitor}; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, checked_ids_param, cmd}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, cmd, ids_param}; /// The most linked files one search looks for. const MAX_LINKS: usize = 1_000; @@ -143,8 +143,14 @@ fn find(s: &mut Session, p: &Value) -> Result { let ids = match p.get("ids") { None | Some(Value::Null) => None, Some(_) => { - let ids = checked_ids_param(s, p, "ids", C)?; - let linkable = |id: &NodeId| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); + // Images and placed documents in opacity-mask art and in the art of symbols, graph + // designs and patterns are named by their ids too (`links.list`). + let ids = ids_param(p, "ids", C)?.unwrap_or_default(); + let kind = |id: &NodeId| st.doc.stored_node(*id).map(|(n, _)| &n.kind); + if let Some(value) = ids.iter().find(|id| kind(id).is_none()).map(|id| id.0) { + return Err(bad(C, format!("no such object id {value}"))); + } + let linkable = |id: &NodeId| matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); if let Some(id) = ids.iter().find(|id| !linkable(id)) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 7df71e7422..d9c2ccf3ad 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -30,6 +30,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, + StoredPlace, StoredRoot, }; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; @@ -43,7 +44,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs and patterns)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -52,7 +53,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns and opacity masks have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", has_doc, update ), @@ -70,7 +71,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs and patterns) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", has_doc, relink ), @@ -79,7 +80,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs and patterns (one row per stored object, never one per symbol instance or pattern tile) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern, name? (of the symbol, graph design or pattern), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -88,7 +89,7 @@ pub fn specs() -> Vec { "Go To Link", [], None, - "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", + "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design or pattern has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", has_doc, go_to ), @@ -156,7 +157,8 @@ impl Status { struct Group { link: LinkInfo, key: String, - /// The objects in the layers (images in symbols and patterns share the content, not an id). + /// The stored objects showing it: in the layers, in opacity-mask art and in the art of symbols, + /// graph designs and patterns ([`Document::visit_stored`]), each once. ids: Vec, /// Placed documents: what they show. document: Option, @@ -177,8 +179,9 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { } } -/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]): an -/// image placed from a DCS file has its plate files of their own too, beside its file. +/// Every linked file of `d` with the ids of the objects showing it, wherever they are stored (for +/// [`crate::link_watch`]): an image placed from a DCS file has its plate files of their own too, +/// beside its file. pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { let mut out = vec![]; for g in groups(d, None).into_iter().filter(|g| !g.ids.is_empty()) { @@ -190,8 +193,10 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { out } -/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when -/// given), then, without `ids`, the ones only symbols and patterns show. +/// The linked images and placed documents of `d` by file and content, wherever they are stored +/// (only `ids` when given), in [`Document::visit_stored`] order. An object whose id an object +/// visited before it has (art being edited on a layer and its definition) counts once, by its +/// first place; without `ids` its content is still grouped. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); @@ -210,24 +215,48 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { g.ids.push(id); } }; - d.walk(|n| { - if let Some((l, key, document)) = linked(n) - && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) - { + let mut seen = std::collections::HashSet::new(); + d.visit_stored(|n, _| { + let Some((l, key, document)) = linked(n) else { return }; + // Id 0 names no object. + let first = n.id.0 != 0 && seen.insert(n.id); + if first && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) { add(l, key, document, Some(n.id)); + } else if ids.is_none() { + add(l, key, document, None); } }); - if ids.is_none() { - d.visit_images(|_, im| { - if let Some(l) = &im.link { - add(l, &im.key, None, None); - } - }); - d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None)); - } out } +/// Stored object `id` of `d` ([`Document::stored_node`]). +fn stored(d: &Document, id: NodeId) -> Option<&Node> { + d.stored_node(id).map(|(n, _)| &**n) +} + +/// Of `ids`, the objects of `d` outside the layer tree (where [`Document::node`] finds none). +fn nested_ids(d: &Document, ids: &[NodeId]) -> std::collections::HashSet { + ids.iter().copied().filter(|id| d.node(*id).is_none()).collect() +} + +/// `links.list`'s `owner` of an object stored at `place` of `d`, when it is not in the layer tree +/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`. +fn owner_json(d: &Document, place: StoredPlace) -> Option { + if place.in_layers() { + return None; + } + let mut owner = match place.root { + StoredRoot::Layer(_) => json!({ "kind": "layers" }), + StoredRoot::Symbol(i) => json!({ "kind": "symbol", "name": d.symbols.get(i).map(|s| s.name.as_str()) }), + StoredRoot::GraphDesign(i) => json!({ "kind": "graphDesign", "name": d.graph_designs.get(i).map(|g| g.name.as_str()) }), + StoredRoot::Pattern { pattern, .. } => json!({ "kind": "pattern", "name": d.patterns.get(pattern).map(|p| p.name.as_str()) }), + }; + if let Some(m) = place.mask_of { + owner["maskOf"] = json!(m.0); + } + Some(owner) +} + /// A linked file where it was found. struct Found { path: String, @@ -493,8 +522,8 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a } /// The linked files of the open document `st` that `links.findFiles` looks for, each with the -/// objects in the layers that show it: the files of objects `ids` whatever their status, else the -/// files that can't be found. +/// stored objects that show it: the files of objects `ids` whatever their status, else the files +/// that can't be found. pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec)> { groups(&st.doc, ids) .into_iter() @@ -708,7 +737,7 @@ fn clip_to( type Remake<'a> = dyn FnMut(&Node, Option<(&PathData, FillRule)>) -> (Node, ClipTo) + 'a; /// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and -/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. +/// opacity masks, where [`Document::node`] does not find them) that `pick` holds for. struct Nested<'a> { pick: &'a dyn Fn(&Node) -> bool, make: &'a mut Remake<'a>, @@ -854,10 +883,10 @@ impl Nested<'_> { } } -/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its -/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also` -/// (a file's path and the content shown), so do the placed documents outside the layer tree that -/// show that content. +/// Placed documents `ids` (in the layers or outside them) show `src`, read from the file `link` +/// points at, each placed as its Placement Options say (`force`: as that Preserve choice says, +/// whatever they say); with `also` (a file's path and the content shown), so do the placed +/// documents outside the layer tree that show that content. fn install_document( d: &mut Document, ids: &[NodeId], @@ -872,8 +901,10 @@ fn install_document( let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o) }; - if let Some(also) = also { - let pick = |n: &Node| shows(n, also, true); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = + |n: &Node| matches!(n.kind, NodeKind::PlacedDocument(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, true))); let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { let mut n = n.clone(); let mut clip = ClipTo::Keep; @@ -1037,26 +1068,39 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ install(d, &[id], read, Some(Preserve::Bounds), None) } -/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement -/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's -/// path and the content shown), so do the images outside the layer tree that show that content. +/// Image objects `ids` (in the layers or outside them) show the file read for `link` (`img`), each +/// placed as its Placement Options say (`force`: as that Preserve choice says, whatever they say); +/// with `also` (a file's path and the content shown), so do the images outside the layer tree that +/// show that content. fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option, also: Option<(&str, &str)>) -> Result<()> { let new_pt = super::place::pt_per_px(img.ppi); let mut clips = vec![]; - if let Some(also) = also { - // Every image picked shows content `also.1`, so one resolution serves them all. - let old_pt = file_pt(d, also.1); - let pick = |n: &Node| shows(n, also, false); - // The clipping path the old content's DCS file drew it through. - let old_clip = d.images.get(also.1).and_then(ImageBlob::separated_clip); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = |n: &Node| matches!(n.kind, NodeKind::Image(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, false))); + // The resolution of each content the images picked show, and the clipping path its DCS file + // drew it through, looked up once each. + let mut old: BTreeMap)> = BTreeMap::new(); + d.visit_stored(|n, _| { + if let NodeKind::Image(im) = &n.kind + && pick(n) + && !old.contains_key(&im.key) + { + old.insert(im.key.clone(), (file_pt(d, &im.key), d.images.get(&im.key).and_then(ImageBlob::separated_clip))); + } + }); let mut make = |n: &Node, held: Option<(&PathData, FillRule)>| { let mut n = n.clone(); let mut clip = ClipTo::Keep; if let NodeKind::Image(im) = &mut n.kind { let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); + let (old_pt, old_clip) = match old.get(&im.key) { + Some((pt, clip)) => (*pt, clip.as_ref()), + None => (super::place::pt_per_px(None), None), + }; let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); - clip = clip_to(im.xf, old_clip.as_ref(), c, &img, xf, held); + clip = clip_to(im.xf, old_clip, c, &img, xf, held); (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); } (n, clip) @@ -1267,7 +1311,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps } } // The objects in symbols, graph designs, patterns and opacity masks that show the same file and - // content, which have no ids here, whatever else their links hold. + // content, whatever else their links hold, those whose id an earlier object has included. if !moved.is_empty() { let mut by_path: BTreeMap> = BTreeMap::new(); for (path, key, document, new) in moved { @@ -1302,13 +1346,25 @@ fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> { } } -/// Linked image objects (or placed documents) `ids` link to `link`. +/// Linked image objects (or placed documents) `ids`, in the layers or outside them, link to `link`. fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { - for id in ids { + let nested = nested_ids(d, ids); + for id in ids.iter().filter(|id| !nested.contains(id)) { if let Some(l) = d.node_mut(*id).and_then(link_mut) { *l = link.clone(); } } + if !nested.is_empty() { + let pick = |n: &Node| nested.contains(&n.id) && linked(n).is_some(); + let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { + let mut out = n.clone(); + if let Some(l) = link_mut(&mut out) { + *l = link.clone(); + } + (out, ClipTo::Keep) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open @@ -1540,7 +1596,7 @@ fn update(s: &mut Session, p: &Value) -> Result { /// Update Links in the active document of `s` for objects `ids` (default: the linked files that /// changed or show their preview), or with `path`, for the placed documents of the file at `path` /// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that -/// show the same content are read again too, and have no ids to report. +/// show the same content are read again too. fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; let (mut reads, mut missing) = (vec![], vec![]); @@ -1606,7 +1662,7 @@ enum Scope { Each, /// The objects given or selected. Selected, - /// Every object in the layers linked to the same file as one given or selected. + /// Every stored object linked to the same file as one given or selected. All, /// The objects given or selected, then every other missing link whose file name is in the new /// file's folder. @@ -1690,7 +1746,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { }; // An image or a placed document, with its link. let image = |id: &NodeId| { - st.doc.node(*id).and_then(|n| match &n.kind { + stored(&st.doc, *id).and_then(|n| match &n.kind { NodeKind::Image(im) => Some(im.link.as_ref()), NodeKind::PlacedDocument(p) => Some(Some(&p.link)), _ => None, @@ -1715,22 +1771,23 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } - // All Instances: the other objects in the layers linked to the same files. + // All Instances: the other stored objects linked to the same files, each once. if scope == Scope::All { let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); - let given: BTreeSet = ids.iter().copied().collect(); + let mut given: BTreeSet = ids.iter().copied().collect(); let mut more = vec![]; - st.doc.walk(|n| { + st.doc.visit_stored(|n, _| { if let Some((l, _, _)) = linked(n) && paths.contains(l.path.as_str()) - && !given.contains(&n.id) + && n.id.0 != 0 + && given.insert(n.id) { more.push(n.id); } }); ids.extend(more); } - if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { + if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&st.doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. @@ -1766,7 +1823,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut images = vec![]; let mut documents: Vec<((u32, bool, PageBox), Vec)> = vec![]; for id in ids { - match st.doc.node(id).map(|n| &n.kind) { + match stored(&st.doc, id).map(|n| &n.kind) { Some(NodeKind::PlacedDocument(pl)) => { let k = (pl.page(), pl.bounding, pl.page_box); match documents.iter_mut().find(|(e, _)| *e == k) { @@ -1916,9 +1973,9 @@ fn copy_into_folder(_: &str, _: &[u8], _: &Path) -> Result<(String, bool)> { // ---------- the Links panel ---------- -/// Image object `id` of `d`. +/// Image object `id` of `d`, wherever it is stored. fn image_of(d: &Document, id: NodeId) -> Option<&ImageObject> { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im), _ => None, } @@ -1936,7 +1993,7 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { .ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match image_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image", id.0))), } } @@ -1950,9 +2007,9 @@ fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> { } } -/// The Placement Options of image or placed document `id` of `d`. +/// The Placement Options of image or placed document `id` of `d`, wherever it is stored. fn placement_of(d: &Document, id: NodeId) -> Option { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im.placement), NodeKind::PlacedDocument(p) => Some(p.placement), _ => None, @@ -1965,12 +2022,12 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match placement_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))), } } -/// Per linked image in the layers of `st`: its file's status, where it was found when away from +/// Per linked image or placed document of `st`, wherever it is stored: its file's status, where it was found when away from /// its path, and whether the image shows its preview (each file probed once). pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { let mut out = BTreeMap::new(); @@ -2071,17 +2128,20 @@ fn list(s: &mut Session, p: &Value) -> Result { } let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() }; let statuses = statuses(st); - let mut objects = vec![]; - st.doc.walk(|n| { - if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) { - objects.push(n); + // The layer tree's objects, top first as the Layers panel lists them, then the others in the + // order they are stored (opacity-mask art, symbols, graph designs, patterns), each once. + let (mut objects, mut nested) = (vec![], vec![]); + let mut seen = std::collections::HashSet::new(); + st.doc.visit_stored(|n, place| { + if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) && n.id.0 != 0 && seen.insert(n.id) { + if place.in_layers() { objects.push((n, place)) } else { nested.push((n, place)) } } }); - // Top first, as the Layers panel lists them. objects.reverse(); + objects.extend(nested); let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut rows = vec![]; - for n in objects { + for (n, place) in objects { let (status, found, preview) = match statuses.get(&n.id) { Some((s, f, p)) => (s.as_str(), f.as_deref(), *p), None => ("embedded", None, false), @@ -2124,6 +2184,9 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } + if let Some(owner) = owner_json(&st.doc, place) { + row["owner"] = owner; + } if interactions.contains(&n.id) { row["dcsTransparency"] = json!(true); } @@ -2144,15 +2207,21 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let id = linked_param(st, p, "links.goTo")?; + // An object in mask art or a definition has no place of its own on the canvas: the selection + // is cleared, and the reply names what holds it. + if let Some(owner) = st.doc.stored_node(id).and_then(|(_, place)| owner_json(&st.doc, place)) { + s.select(|_, sel| sel.set([]))?; + return Ok(json!({ "id": id.0, "onCanvas": false, "owner": owner })); + } let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default(); s.select(|_, sel| sel.set([id]))?; - Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] })) + Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1], "onCanvas": true })) } fn embed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.embed"; let st = s.doc()?; - let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind); + let kind = |id: &NodeId| stored(&st.doc, *id).map(|n| &n.kind); let linked = |id: &NodeId| match kind(id) { Some(NodeKind::Image(im)) => im.link.is_some(), Some(NodeKind::PlacedDocument(_)) => true, @@ -2210,7 +2279,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { install(d, &ids, read, Some(Preserve::Bounds), None)?; } for id in &done { - if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { im.link = None; } } @@ -2238,7 +2307,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.unembed"; let st = s.doc()?; let id = image_param(st, p, C)?; - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; if im.link.is_some() { return Err(bad(C, format!("image {} is linked already", id.0))); @@ -2324,7 +2393,7 @@ fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { /// Link Info of placed document `id`. fn placed_info(st: &crate::DocState, id: NodeId) -> Result { - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) }; let size = n.geometric_bounds().unwrap_or_default(); let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0)); @@ -2404,7 +2473,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { }; s.edit("Placement Options", |d, _| { for id in &ids { - if let Some(o) = d.node_mut(*id).and_then(placement_mut) { + if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { set(o); } } diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index c7c82c7625..2a8d074617 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Edit Profile", [], None, - "{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first → document.open's result", + "{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first; under automation roots it needs write and read access to the file, and an error names the file by its file name outside the read roots → document.open's result", always, edit_profile ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Reset Profile", [], None, - "{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open → {name, reset: whether a file was deleted}", + "{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open; under automation roots it needs write access to the file, and an error names the file by its file name outside the read roots → {name, reset: whether a file was deleted}", always, reset_profile ), @@ -999,10 +999,17 @@ fn list_profiles(s: &mut Session, _: &Value) -> Result { Ok(json!({ "profiles": profiles, "folder": s.new_doc_profile_dir().and_then(shown) })) } +/// `msg`, an error about the profile 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 profile_error(msg: String, path: &str) -> super::EngineError { + super::fileio::naming_in_error(super::EngineError::Other(msg), path) +} + /// The file of profile `name` for `cmd` to change, checked against the request's write access. fn writable_profile(s: &Session, name: &str, cmd: &str) -> Result { let path = s.profile_path(name).ok_or_else(|| bad(cmd, "New Document profiles need the preferences folder, which this run doesn't have"))?; - crate::file_access::check_write(&path.to_string_lossy()).map_err(super::EngineError::Other)?; + let file = path.to_string_lossy(); + crate::file_access::check_write(&file).map_err(|e| profile_error(e, &file))?; Ok(path) } @@ -1012,13 +1019,13 @@ fn edit_profile(s: &mut Session, p: &Value) -> Result { let file = writable_profile(s, name, C)?; let path = file.to_string_lossy().to_string(); // document.open reads it next: a profile it can't open is not written either. - crate::file_access::check_read(&path).map_err(super::EngineError::Other)?; + crate::file_access::check_read(&path).map_err(|e| profile_error(e, &path))?; if !file.exists() { let bytes = vectorcraft_format::save(&shipped_profile(s, name), false); file.parent() .map_or(Ok(()), std::fs::create_dir_all) .and_then(|()| std::fs::write(&file, bytes)) - .map_err(|e| super::EngineError::Other(format!("{path}: {e}")))?; + .map_err(|e| profile_error(format!("{path}: {e}"), &path))?; } s.execute("document.open", &json!({ "path": path })) } @@ -1031,7 +1038,7 @@ fn reset_profile(s: &mut Session, p: &Value) -> Result { let reset = match std::fs::remove_file(&file) { Ok(()) => true, Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(e) => return Err(super::EngineError::Other(format!("{path}: {e}"))), + Err(e) => return Err(profile_error(format!("{path}: {e}"), &path)), }; Ok(json!({ "name": name, "reset": reset })) } diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index ff5b950d03..e460712376 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -169,28 +169,47 @@ pub(super) fn lock(s: &mut Session, _: &Value) -> Result { ok() } -/// The selected objects ([`selected_roots`]) that Lock › Selection and Hide › Selection change: in -/// isolation mode, only those inside the isolated object, where a path of the isolated compound -/// path is an object of its own. +/// The selected objects ([`selected_roots`]) and ruler guides that Lock › Selection and Hide › +/// Selection change: in isolation mode, only the objects inside the isolated object, where a path +/// of the isolated compound path is an object of its own. A guide counts while it can be edited +/// (it and its layer shown and unlocked, and Lock Guides off). fn selected_lock_hide_roots(s: &Session) -> Result> { - match crate::cmd::ordinary_isolation(s.doc()?) { + let st = s.doc()?; + match crate::cmd::ordinary_isolation(st) { Some(root) => crate::cmd::edit::selected_roots_inside(s, root, false), - None => selected_roots(s), + None => { + let mut ids = selected_roots(s)?; + if !s.guides_locked() { + let d = &st.doc; + ids.extend(st.selection.guides.iter().copied().filter(|id| d.ruler_guide(*id).is_some_and(|g| d.guide_editable(&g)))); + } + Ok(ids) + } } } +/// The objects and ruler guides in `d` that `f` picks (layers never). fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { let mut v = vec![]; d.walk(|n| { - if !n.is_layer() && !n.is_ruler_guide() && f(n) { + if !n.is_layer() && f(n) { v.push(n.id) } }); v } +/// Select what Unlock All or Show All changed (`ids`): the objects, and the ruler guides among +/// them as guides (a guide that still can't be edited is then dropped from the selection). +fn select_changed(d: &Document, sel: &mut vectorcraft_doc::Selection, ids: &[NodeId]) { + let guides: std::collections::HashSet = d.ruler_guides().into_iter().filter_map(|g| g.id).collect(); + let (on_guides, objects): (Vec, Vec) = ids.iter().copied().partition(|id| guides.contains(id)); + sel.set(objects); + sel.guides = on_guides; +} + /// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole -/// document, inside the isolated object in isolation mode, or with `scope: "group"` inside the +/// document (its ruler guides included), inside the isolated object in isolation mode, or with `scope: "group"` inside the /// groups that hold the selected objects (in isolation mode, only the objects of those groups /// that are inside the isolated object). A selected group is the group that holds its own /// members, and the objects of a group inside another such group are taken once. With @@ -253,7 +272,7 @@ pub(super) fn unlock_all(s: &mut Session, p: &Value) -> Result { n.locked = false; } } - sel.set(ids.iter().copied()); + select_changed(d, sel, &ids); Ok(()) })?; Ok(json!({ "count": ids.len() })) @@ -292,7 +311,7 @@ pub(super) fn show_all(s: &mut Session, p: &Value) -> Result { n.visible = true; } } - sel.set(ids.iter().copied()); + select_changed(d, sel, &ids); Ok(()) })?; Ok(json!({ "count": ids.len() })) diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 5c66ec8442..21d6530e53 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -7,7 +7,7 @@ use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; use super::{CommandSpec, cmd, has_doc, has_multi, has_selection, has_selection_or_guides}; -use crate::cmd::has_selected_member; +use crate::cmd::has_selected_member_or_guide; pub fn specs() -> Vec { vec![ @@ -151,8 +151,8 @@ pub fn specs() -> Vec { "Selection", ["Object", "Lock"], Some("Cmd+2"), - "{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}", - has_selected_member, + "{ids?: [id…] (selected first; default: the selection, selected ruler guides included; in isolation mode, only the objects inside the isolated object)}", + has_selected_member_or_guide, lock ), cmd!( @@ -160,7 +160,7 @@ pub fn specs() -> Vec { "Unlock All", ["Object"], Some("Cmd+Alt+2"), - "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them → {count}", + "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them; document scope outside isolation mode also unlocks ruler guides locked on their own → {count}", has_doc, unlock_all ), @@ -169,8 +169,8 @@ pub fn specs() -> Vec { "Selection", ["Object", "Hide"], Some("Cmd+3"), - "{ids?: [id…] (selected first; default: the selection; in isolation mode, only those inside the isolated object)}", - has_selected_member, + "{ids?: [id…] (selected first; default: the selection, selected ruler guides included; in isolation mode, only the objects inside the isolated object)}", + has_selected_member_or_guide, hide ), cmd!( @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "Show All", ["Object"], Some("Cmd+Alt+3"), - "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them → {count}", + "{scope?: \"document\"|\"group\" (default document, which in isolation mode is the isolated object; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them; document scope outside isolation mode also shows ruler guides hidden on their own → {count}", has_doc, show_all ), diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 296cef4dc2..7dc8aec1c9 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -12,7 +12,8 @@ //! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move //! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the //! path, the objects between it and that place each moving one place toward where it was; hidden -//! and locked objects keep their places. +//! and locked objects keep their places. `object.objectsOnPath.detach` takes one object out of its +//! group, just above it, and the others move up the path. //! //! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back //! on its path: editing the path moves them, an object moved on its own goes back to its place, @@ -21,7 +22,8 @@ //! //! Behavior: (the tool and //! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution, -//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand). +//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Detach and +//! the objects that move up the path after it; Expand). use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -75,6 +77,15 @@ pub fn specs() -> Vec { has_doc, move_member ), + cmd!( + "object.objectsOnPath.detach", + "Detach Object from Path", + [], + None, + "{member?: id of an object of an Objects on Path group (default: the object chosen with the selected group's Select/Move widget); neither the object nor the group may be hidden or locked} take one object off the path, out of its group and just above the group in the Layers panel, where it stays as drawn and is selected alone. The group's other objects move up the path in their order: with Auto Space they spread evenly again, and a Space value and the start are kept. The group and its path stay, even with one object or none left → {group, member}", + has_doc, + detach + ), cmd!( "object.objectsOnPath.expand", "Expand", @@ -383,6 +394,47 @@ fn move_member(s: &mut Session, p: &Value) -> Result { Ok(json!({ "group": g.0, "member": member.0, "index": index })) } +// ---------- Detach ---------- + +/// The object chosen with the Select/Move widget of the one selected Objects on Path group. +fn chosen_member(s: &Session) -> Option { + let st = s.active()?; + match st.selection.objects[..] { + [_] => st.selection.on_path_member, + _ => None, + } +} + +fn detach(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.objectsOnPath.detach"; + const CHOOSE: &str = "choose the object to detach with its Select/Move widget"; + let (g, member) = if p.get("member").is_none_or(Value::is_null) { + let chosen = chosen_member(s).ok_or_else(|| bad(C, CHOOSE))?; + let found = member_param(s, &json!({ "member": chosen.0 }), "member", C)?; + if s.active().is_none_or(|st| st.selection.objects[..] != [found.0]) { + return Err(bad(C, CHOOSE)); + } + found + } else { + member_param(s, p, "member", C)? + }; + // The object goes just above the group, in the group's parent, and keeps where it is drawn. + // When the group is itself an object on another group's path, the object goes above that + // group instead (and so on outward), so it does not join that path. The refresh after the edit + // removes it from the group's record and moves the others up the path. + s.edit("Detach Object from Path", |d, sel| { + let mut above = g; + while let Some(p) = d.parent_of(above).filter(|p| d.node(*p).is_some_and(is_objects_on_path)) { + above = p; + } + let (parent, index, _) = d.position(above).ok_or(EngineError::NoNode(above))?; + d.move_node(member, parent, index + 1)?; + sel.set([member]); + Ok(()) + })?; + Ok(json!({ "group": g.0, "member": member.0 })) +} + // ---------- Expand ---------- fn expand(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index eaf1597423..85d11ea7ec 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind}; -use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; +use super::appearance::{ItemTarget, edit_items, edit_items_then, item_target, row_targets}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; use super::{ Affine, CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, leaf_targets, ok, @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, transparency ), @@ -1062,7 +1062,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result { return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. - let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? }; + let item_ids = if editing_mask { ids } else { row_targets(s, p)? }; if explicit { let d = &s.doc()?.doc; if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index c38854ae76..ddbaec22a2 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -773,9 +773,7 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> { NO_SAVE_FORMAT } "document.export" | "document.exportSelection" | "document.serialize" => { - // `richBlack` is Appearance of Black's setting for a raster export, and `params` an - // exporter plug-in's own. - keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]); + keys.extend(super::fileio::EXPORT_PARAMS); keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name)); NO_FORMAT } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 36444878ef..20f19ef803 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -58,6 +58,9 @@ use crate::{EngineError, MAX_COORD}; const PLACE: &str = "file.place"; const QUEUE: &str = "file.place.queue"; +/// The error of a file read apart from the session while another document is active, or after the +/// document it was read for closed ([`Session::place_read`], [`Session::place_queue_read`]). +const NOT_ACTIVE: &str = "the document it was prepared for is no longer active"; /// The most files one `file.place.queue` loads. const MAX_QUEUE: usize = 100; /// The longest thumbnail side `thumbnail` may ask for (px). @@ -503,11 +506,13 @@ fn fit(natural: Rect, r: Rect) -> Affine { Affine::translate(r.center().to_vec2()) * Affine::scale(k) * Affine::translate(-natural.center().to_vec2()) } -/// What `file.place` takes from the session before it reads the file: the params, the object -/// Replace swaps and the artboard under `at`, which a DXF drawing is laid out on -/// ([`Session::prepare_place`]). [`PlaceJob::read`] reads and decodes the file without the session, -/// on any thread. +/// What `file.place` takes from the session before it reads the file: the document it places into, +/// the params, the object Replace swaps and the artboard under `at`, which a DXF drawing is laid +/// out on ([`Session::prepare_place`]). [`PlaceJob::read`] reads and decodes the file without the +/// session, on any thread. pub struct PlaceJob { + /// The document it was prepared in (`DocState::uid`). + doc: u64, params: Value, old: Option, /// The object Replace swaps as it was when Place started. @@ -560,10 +565,14 @@ impl Session { /// Run `file.place` with the params of `read` as a command (checked and journaled as any /// other) that places the file `read` holds instead of reading it again → `file.place`'s - /// reply. The object Replace swaps must still be in the document as it was when Place started; - /// when the selection changed meanwhile, `select.set` selects that object again first, so a - /// replay of the journal replaces the same object. + /// reply. The document Place started in must be the active one, and the object Replace swaps + /// must still be in it as it was when Place started; when the selection changed meanwhile, + /// `select.set` selects that object again first, so a replay of the journal replaces the same + /// object. pub fn place_read(&mut self, read: PlaceRead) -> Result { + if self.doc()?.uid != read.job.doc { + return Err(bad(PLACE, NOT_ACTIVE)); + } let params = read.job.params.clone(); if let Some(old) = read.job.old { let st = self.doc()?; @@ -615,7 +624,7 @@ fn prepare(s: &Session, p: &Value) -> Result { (true, _) => return Err(bad(PLACE, "replace: select the one object to replace")), }; let replaced = old.and_then(|id| st.doc.node(id)).cloned(); - Ok(PlaceJob { params: p.clone(), old, replaced, target: board_at(&st.doc, at) }) + Ok(PlaceJob { doc: st.uid, params: p.clone(), old, replaced, target: board_at(&st.doc, at) }) } /// Place the file `read` holds in the active document. @@ -755,6 +764,8 @@ fn queued_files(p: &Value) -> Result> { /// ([`Session::prepare_place_queue`]). [`QueueJob::read`] reads them without the session, on any /// thread. pub struct QueueJob { + /// The document it was prepared in (`DocState::uid`). + doc: u64, params: Value, files: Vec, } @@ -762,6 +773,8 @@ pub struct QueueJob { /// The files [`QueueJob::read`] read, which [`Session::place_queue_read`] loads the place cursor /// with. pub struct QueueRead { + /// The document the files were read for (`DocState::uid`). + doc: u64, params: Value, entries: Vec, files: Vec, @@ -776,13 +789,13 @@ impl QueueJob { /// Read every file for its size and thumbnail, as `file.place.queue` does, without the session. pub fn read(self) -> Result { - read_queue(self.params, self.files) + read_queue(self.doc, self.params, self.files) } } -/// Read each of `queued`, the files of `file.place.queue` with params `p`, for its size and -/// thumbnail. -fn read_queue(p: Value, queued: Vec) -> Result { +/// Read each of `queued`, the files of `file.place.queue` with params `p` for the document `doc`, +/// for its size and thumbnail. +fn read_queue(doc: u64, p: Value, queued: Vec) -> Result { let thumbnail = p.get("thumbnail").and_then(Value::as_f64); let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); for mut q in queued { @@ -823,7 +836,7 @@ fn read_queue(p: Value, queued: Vec) -> Result { } } } - Ok(QueueRead { params: p, entries, files, skipped }) + Ok(QueueRead { doc, params: p, entries, files, skipped }) } impl Session { @@ -834,12 +847,17 @@ impl Session { has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?; check_switches(params, &["link", "template"], QUEUE)?; check_numbers(params, &["thumbnail"], QUEUE)?; - Ok(QueueJob { params: params.clone(), files: queued_files(params)? }) + let files = queued_files(params)?; + Ok(QueueJob { doc: self.doc()?.uid, params: params.clone(), files }) } /// Run `file.place.queue` with the params of `read` as a command that loads the place cursor - /// with the files `read` holds instead of reading them again → `file.place.queue`'s reply. + /// with the files `read` holds instead of reading them again → `file.place.queue`'s reply. The + /// document the files were read for must be the active one. pub fn place_queue_read(&mut self, read: QueueRead) -> Result { + if self.doc()?.uid != read.doc { + return Err(bad(QUEUE, NOT_ACTIVE)); + } let params = read.params.clone(); self.queue_hand_off = Some(read); let r = self.execute(QUEUE, ¶ms); @@ -944,7 +962,8 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), }; - let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + // An image in opacity-mask art or a definition's art is found by its id too (`links.list`). + let n = st.doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) }; // Effective resolution: 72 pt per inch over the points one pixel spans along each axis. let [a, b, c, d, ..] = im.xf.as_coeffs(); diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 15a39f06e5..9cd28b7e25 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use vectorcraft_brush::{Brush, BrushKind}; use vectorcraft_color::Paint; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, PatternDef, Symbol}; use super::unique_name; @@ -118,11 +118,12 @@ impl Used { type Renames = BTreeMap, bool)>; /// Names in `dst` for the `used` definitions of `src` (`def`): the same name when `dst` has none -/// (copied) or an equal one (shared), else a free variant (`taken`, copied). -fn names( +/// (copied) or one that is the `same` (shared), else a free variant (`taken`, copied). +fn names( used: &[String], def: impl Fn(&str) -> Option, existing: impl Fn(&str) -> Option, + same: impl Fn(&T, &T) -> bool, taken: impl Fn(&str) -> bool, ) -> Renames { let mut out = Renames::new(); @@ -130,7 +131,7 @@ fn names( let Some(d) = def(name) else { continue }; let entry = match existing(name) { None => (Some(name.clone()), true), - Some(e) if e == d => (Some(name.clone()), false), + Some(e) if same(&e, &d) => (Some(name.clone()), false), Some(_) => { (Some(unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true) } @@ -140,6 +141,65 @@ fn names( out } +/// `n` as `dst` would copy it in ([`Document::reid`]), with the ids a document of its own gives it: +/// art copied in once, which differs from the file's only in its ids, compares equal to the file's. +fn canon(dst: &Document, n: &Node) -> Node { + let mut own = Document::new(0.0, 0.0); + // Pixels the art carries take a key `dst` doesn't use for other pixels, as they do in `dst`. + let mut keys = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| { + if let NodeKind::Image(im) = &c.kind + && im.pixels.is_some() + { + keys.push(im.key.clone()); + } + }); + for (k, blob) in &dst.images { + if keys.iter().any(|key| k == key || k.strip_prefix(key.as_str()).is_some_and(|rest| rest.starts_with('-'))) { + own.images.insert(k.clone(), blob.clone()); + } + } + own.reid(n) +} + +/// Do symbols `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_symbol(dst: &Document, a: &Symbol, b: &Symbol) -> bool { + let own = |s: &Symbol| Symbol { art: Arc::new(canon(dst, &s.art)), ..s.clone() }; + own(a) == own(b) +} + +/// Do patterns `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_pattern(dst: &Document, a: &PatternDef, b: &PatternDef) -> bool { + let bare = |p: &PatternDef| PatternDef { art: vec![], ..p.clone() }; + bare(a) == bare(b) && a.art.len() == b.art.len() && a.art.iter().zip(&b.art).all(|(x, y)| canon(dst, x) == canon(dst, y)) +} + +/// Does art `a` use a resource that `m` (images, symbols, patterns, brushes, swatches) gives +/// another name, or a swatch whose link `m` drops? +fn uses_moved(a: &Node, m: [&Renames; 5]) -> bool { + let moved = |map: &Renames, k: &str| map.get(k).is_some_and(|(new, _)| new.as_deref() != Some(k)); + let (mut a, mut any) = (a.clone(), false); + refs(&mut a, &mut |r| { + any |= match r { + Ref::Image(k) => moved(m[0], k), + Ref::Symbol(k) => moved(m[1], k), + Ref::Pattern(k) => moved(m[2], k), + Ref::Brush(k) => moved(m[3], k), + Ref::Swatch(link) => link.as_deref().is_some_and(|k| moved(m[4], k)), + } + }); + any +} + +/// Copy each of `shared` in `map` under a free name, as [`names`] does for a definition `dst` +/// holds differently. +fn unshare(map: &mut Renames, shared: &[String], used: &[String], taken: impl Fn(&str) -> bool) { + for name in shared { + let free = unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || map.values().any(|(n, _)| n.as_deref() == Some(c))); + map.insert(name.clone(), (Some(free), true)); + } +} + /// The renamed resources whose definitions are copied: (name in `src`, name in `dst`). fn copies(m: &Renames) -> Vec<(&str, &str)> { m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect() @@ -194,14 +254,17 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { } } - let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |c| dst.images.contains_key(c)); - let symbols = names( + let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |a, b| a == b, |c| dst.images.contains_key(c)); + // Symbol and pattern art copied in takes ids of `dst`, so their art is compared by what it + // draws: placing the same file again shares what the first placement brought. + let mut symbols = names( &used.symbols, |n| src.symbols.iter().find(|s| s.name == n), |n| dst.symbols.iter().find(|s| s.name == n), + |a, b| same_symbol(dst, a, b), |c| dst.symbols.iter().any(|s| s.name == c), ); - let patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |c| dst.pattern(c).is_some()); + let mut patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |a, b| same_pattern(dst, a, b), |c| dst.pattern(c).is_some()); // A swatch the document defines differently isn't renamed (that would add look-alike swatches): // the colours linked to it keep their look, unlinked. let swatches: Renames = used @@ -236,12 +299,33 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { any }; let mut dst_brushes = vectorcraft_brush::library(dst); - let brushes = names( + let mut brushes = names( &used.brushes, |n| src_brushes.iter().find(|b| b.name == n).map(|b| (b, moves_art(b))), |n| dst_brushes.iter().find(|b| b.name == n).map(|b| (b, false)), + |a, b| a == b, |c| dst_brushes.iter().any(|b| b.name == c), ); + // Likewise a symbol, pattern or brush whose art uses a resource that moves isn't the + // document's of that name: it is copied under a free name, which can move what uses it in turn. + loop { + let maps = [&images, &symbols, &patterns, &brushes, &swatches]; + let shared = |map: &Renames| map.iter().filter(|(_, (_, copy))| !*copy).map(|(k, _)| k.clone()).collect::>(); + let sym: Vec = + shared(&symbols).into_iter().filter(|n| src.symbols.iter().any(|s| s.name == *n && uses_moved(&s.art, maps))).collect(); + let pat: Vec = + shared(&patterns).into_iter().filter(|n| src.pattern(n).is_some_and(|p| p.art.iter().any(|a| uses_moved(a, maps)))).collect(); + let bru: Vec = shared(&brushes) + .into_iter() + .filter(|n| src_brushes.iter().find(|b| b.name == *n).is_some_and(|b| brush_art(&mut b.clone()).into_iter().any(|a| uses_moved(a, maps)))) + .collect(); + if sym.is_empty() && pat.is_empty() && bru.is_empty() { + break; + } + unshare(&mut symbols, &sym, &used.symbols, |c| dst.symbols.iter().any(|s| s.name == c)); + unshare(&mut patterns, &pat, &used.patterns, |c| dst.pattern(c).is_some()); + unshare(&mut brushes, &bru, &used.brushes, |c| dst_brushes.iter().any(|b| b.name == c)); + } let mut rename = |r: Ref| { let (map, k) = match r { @@ -272,6 +356,8 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { if let Some(mut def) = src.symbols.iter().find(|s| s.name == old).cloned() { def.name = new.to_string(); refs(Arc::make_mut(&mut def.art), &mut rename); + // The art takes ids of `dst`: those of `src` may name objects of `dst` already. + def.art = Arc::new(dst.reid(&def.art)); dst.symbols.push(def); } } @@ -280,6 +366,7 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { def.name = new.to_string(); for a in &mut def.art { refs(Arc::make_mut(a), &mut rename); + *a = Arc::new(dst.reid(a)); } dst.patterns.push(def); } diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 9f58e4851d..8e4afaa295 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -378,8 +378,10 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { /// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file /// without Link gives (its resources joining `d`, renamed where `d` uses a name for something /// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand. +/// A placed document in opacity-mask art or in the art of a symbol, graph design or pattern is +/// replaced where it is stored. pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let n = d.stored_node(id).map(|(n, _)| Node::clone(n)).ok_or(EngineError::NoNode(id))?; let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) }; let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) @@ -395,6 +397,10 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m); let mut out = n.clone(); out.kind = art.kind; + if d.node(id).is_none() { + *d.stored_node_mut(id).ok_or(EngineError::NoNode(id))? = out; + return Ok(()); + } let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; d.remove(id)?; d.insert(par, index, out)?; diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 8071c1df3d..21d9d65c3d 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -314,6 +314,7 @@ pub(super) const ALLOWED: &[&str] = &[ "object.move", "object.nudge", "object.objectsOnPath.attach", + "object.objectsOnPath.detach", "object.objectsOnPath.expand", "object.objectsOnPath.moveMember", "object.objectsOnPath.options", diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 7920df223e..d4ad54932e 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -455,10 +455,10 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> { /// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they /// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`), the /// Perspective Grid Options (`perspective.widget.options`), the Pathfinder Options -/// (`object.pathfinder.options`) and the Snap to Glyph options (`view.snapToGlyph.options`). -/// `prefs.get` and `prefs.set` take them by key (a partial object updates what it names) and -/// `prefs.reset` without a category resets them. -pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinder", "glyphSnap"]; +/// (`object.pathfinder.options`), the Snap to Glyph options (`view.snapToGlyph.options`) and the +/// Pixel Snapping Options (`view.snapToPixel.options`). `prefs.get` and `prefs.set` take them by key +/// (a partial object updates what it names) and `prefs.reset` without a category resets them. +pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinder", "glyphSnap", "pixelSnap"]; /// Validate a value for preference group `key` against `current`. fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result { @@ -478,6 +478,10 @@ fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result< let cur: vectorcraft_tools::GlyphSnap = serde_json::from_value(current.clone()).unwrap_or_default(); cur.merged(v).map(|o| json!(o)) } + "pixelSnap" => { + let cur: vectorcraft_tools::PixelSnap = serde_json::from_value(current.clone()).unwrap_or_default(); + cur.merged(v).map(|o| json!(o)) + } "perspectiveWidget" => { let mut merged = current.clone(); if let (Some(o), Some(p)) = (merged.as_object_mut(), v.as_object()) { @@ -789,7 +793,7 @@ pub fn specs() -> Vec { "Export Preferences", [], None, - "{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder and glyphSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}", + "{path?} write the preferences as a JSON file {format: \"vectorcraft-preferences\", version: 1, prefs: {key: value, …}}: every preference prefs.list lists and the eyedropper, perspectiveWidget, pathfinder, glyphSnap and pixelSnap groups, without the folder preferences fontsFolder, pluginsFolder, recoveryFolder and templatesFolder and without saved presets; a file over 1 MB, which prefs.import would refuse, is an error and is not written → {path, count}; without path → {data: the file's text, count}", always, export ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index bca9ed6ea9..65fe18b282 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -14,7 +14,7 @@ //! paint shows instead of the character colour under it. use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. 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 (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (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 (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "New Graphic Style", ["Window", "Graphic Styles"], None, - "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", + "{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}", has_doc, style_new ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked 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) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked 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) → {name}", has_doc, style_redefine ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Break Link to Graphic Style", ["Window", "Graphic Styles"], None, - "{ids?|id?, target?: \"object\"|\"contents\"} unlink the objects (default: the selection) from their graphic style; they keep their look", + "{ids?|id?, target?: \"object\"|\"contents\"} unlink the objects (default: the selection) from their graphic style; they keep their look. A graph series group's id stands for the group and the paths directly in it", has_doc, style_break_link ), @@ -307,10 +307,11 @@ fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node) impl Session { /// The graphic style of the first selected object: the one it was last linked to, and whether - /// it is still linked (still has that style's look). + /// it is still linked (still has that style's look). A graph series group that holds nothing of its own stands + /// for the topmost path directly inside it, which a style applied to the group links. pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> { let st = self.active()?; - let n = st.doc.node(*st.selection.subjects().first()?)?; + let n = super::appearance::look_holder(&st.doc, st.doc.node(*st.selection.subjects().first()?)?); let g = st.doc.graphic_style_by_id(n.extras().graphic_style?)?; Some((g, in_sync(n, g))) } @@ -331,7 +332,11 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let i = style_index(&s.doc()?.doc, name)?; let add = bool_or(p, "add", false); let chars = s.prefs.override_char_color; - let ids = super::appearance::appearance_targets(s, p)?; + let own = super::appearance::appearance_targets(s, p)?; + let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own); + // The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves, + // and a style added on top unlinks them. + let groups = super::appearance::stood_for(&own, &ids); if ids.is_empty() { if p.get("ids").is_some() || p.get("id").is_some() { return Err(bad("graphicStyle.apply", "no such objects")); @@ -353,6 +358,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { n.extras_mut().graphic_style = None; } } + // The series groups their paths stand for are unlinked with them. + set_link(d, &groups, None); // The graph stores the rows of the kinds the style shows, now on top of each stack. let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); let kinds = (shows(true), shows(false)); @@ -364,6 +371,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { for id in &ids { apply_style(d, *id, &g, &mut shared, chars); } + super::appearance::clear_stood_for(d, &groups); super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } Ok(()) @@ -372,8 +380,11 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { } fn style_new(s: &mut Session, p: &Value) -> Result { - let (src, n) = source(s, p, "graphicStyle.new")?; + let (_, n) = source(s, p, "graphicStyle.new")?; let d = &s.doc()?.doc; + // A series group that holds nothing of its own shows the look of its paths: the style is made from the topmost. + let n = super::appearance::look_holder(d, n); + let src = n.id; let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { Some(n) => unique_name(n, |x| d.graphic_style(x).is_some()), None => d.new_graphic_style_name(), @@ -459,6 +470,9 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let cmd = "graphicStyle.redefine"; let (src, n) = source(s, p, cmd)?; let d = &s.doc()?.doc; + // A series group that holds nothing of its own is redefined from the topmost of its paths, which a style links. + let n = super::appearance::look_holder(d, n); + let src = if n.id == src { src } else { n.id }; let i = match str_param(p, "name") { Some(name) => style_index(d, name)?, None => n @@ -480,12 +494,18 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src); let (follow, edited): (Vec, Vec) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old))); set_link(d, &edited, None); + // A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and + // goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look. + let mut follow_marks = super::graph::series_group_marks(d, &follow); + let edited: HashSet = edited.into_iter().collect(); + follow_marks.retain(|nid| *nid != src && !edited.contains(nid)); + super::appearance::clear_stood_for(d, &super::appearance::stood_for(&follow, &follow_marks)); let g = d.graphic_styles[i].clone(); let mut shared = None; - for nid in &follow { + for nid in &follow_marks { apply_style(d, *nid, &g, &mut shared, chars); } - super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); + super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); Ok(()) })?; @@ -493,7 +513,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } fn style_break_link(s: &mut Session, p: &Value) -> Result { - let ids = super::appearance::appearance_targets(s, p)?; + let ids = super::appearance::whole_targets(s, p)?; s.edit("Break Link to Graphic Style", |d, _| { set_link(d, &ids, None); Ok(()) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 22c1c0c929..d0713a243a 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -410,6 +410,8 @@ fn gradient_key(g: &Gradient) -> Option> { let method = match g.method { GradientMethod::Classic => 0, GradientMethod::Perceptual => 1, + GradientMethod::HueClockwise => 2, + GradientMethod::HueCounterclockwise => 3, }; Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::>()?)) } diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 560da3942c..6e26e9a9e6 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -1,5 +1,6 @@ //! View → New View… / Edit Views…: saved views stored in the document; per-document view -//! toggles (Show Transparency Grid); the Character panel's Snap to Glyph options. +//! toggles (Show Transparency Grid); the Character panel's Snap to Glyph options; the Pixel +//! Snapping Options. use serde_json::{Value, json}; use vectorcraft_apischema::{Bound, Param, ParamSchema, Type, Writes}; @@ -72,6 +73,18 @@ static GLYPH_SNAP: ParamSchema = ParamSchema::new( .returns("the options") .writes(Writes::Preferences); +static PIXEL_SNAP: ParamSchema = ParamSchema::new( + &[ + Param::new("drawing", Type::Boolean).optional(), + Param::new("moving", Type::Boolean).optional(), + Param::new("scaling", Type::Boolean).optional(), + ], + "the Pixel Snapping Options, kept in the preferences (the pixelSnap group, all on by default): which edits View › Snap to Pixel lands on whole pixels: drawing (the points the drawing tools place, the rulers' origin and measured points), moving (the selection, its anchors and segments, artboards and ruler guides moved, and the Rotate, Reflect and Shear tools' drags) and scaling (the edges a bounding-box, Free Transform, artboard or slice handle drags, and the Scale tool's drag); an edit whose option is off snaps as it does with Snap to Pixel off; {} reads them", +) +.returns("the options") +.writes(Writes::Preferences) +.since(4); + pub fn specs() -> Vec { vec![ cmd!( @@ -125,6 +138,16 @@ pub fn specs() -> Vec { glyph_snap_options, schema: &GLYPH_SNAP ), + cmd!( + query "view.snapToPixel.options", + "Pixel Snapping Options", + [], + None, + "{drawing?: bool, moving?: bool, scaling?: bool} the Pixel Snapping Options, kept in the preferences (the pixelSnap group, all on by default): which edits View › Snap to Pixel lands on whole pixels: drawing (the points the drawing tools place, the rulers' origin and measured points), moving (the selection, its anchors and segments, artboards and ruler guides moved, and the Rotate, Reflect and Shear tools' drags) and scaling (the edges a bounding-box, Free Transform, artboard or slice handle drags, and the Scale tool's drag); an edit whose option is off snaps as it does with Snap to Pixel off; {} reads them → the options", + always, + pixel_snap_options, + schema: &PIXEL_SNAP + ), ] } @@ -133,6 +156,11 @@ fn glyph_snap_options(s: &mut Session, p: &Value) -> Result { Ok(json!(s.prefs.glyph_snap)) } +fn pixel_snap_options(s: &mut Session, p: &Value) -> Result { + s.prefs.pixel_snap = s.prefs.pixel_snap.merged(p).map_err(|e| bad("view.snapToPixel.options", e))?; + Ok(json!(s.prefs.pixel_snap)) +} + fn caps_lock(s: &mut Session, p: &Value) -> Result { if let Some(on) = p.get("on") { s.caps_lock = on.as_bool().ok_or_else(|| bad("view.capsLock", "on must be true or false"))?; @@ -379,15 +407,17 @@ mod tests { ("view.saved.list", json!({"all": true}), "`all`"), ("view.transparencyGrid", json!({"on": 1}), "`on`"), ("view.snapToGlyph.options", json!({"bounds": "off"}), "`bounds`"), + ("view.snapToPixel.options", json!({"moving": "off"}), "`moving`"), + ("view.snapToPixel.options", json!({"drawing": false, "rotating": false}), "`rotating`"), ] { let st = s.doc().unwrap(); let doc = (st.doc.views.clone(), st.history.undo.len(), st.is_dirty()); - let (grid, caps, snap) = (st.transparency_grid, s.caps_lock, s.prefs.glyph_snap); + let (grid, caps, snap, pixel) = (st.transparency_grid, s.caps_lock, s.prefs.glyph_snap, s.prefs.pixel_snap); let e = s.execute(id, ¶ms).unwrap_err().to_string(); assert!(e.contains(id) && e.contains(named), "{id} {params}: {e}"); let st = s.doc().unwrap(); assert_eq!((&st.doc.views, st.history.undo.len(), st.is_dirty()), (&doc.0, doc.1, doc.2), "{id} {params}"); - assert_eq!((st.transparency_grid, s.caps_lock, s.prefs.glyph_snap), (grid, caps, snap), "{id} {params}"); + assert_eq!((st.transparency_grid, s.caps_lock, s.prefs.glyph_snap, s.prefs.pixel_snap), (grid, caps, snap, pixel), "{id} {params}"); } } diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 89642a6e19..ee594bae25 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -164,6 +164,11 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ "object.ungroup", ]; +/// Commands whose `ids` (or `id`) may name ruler guide nodes as well as objects: the Layers panel's +/// eye and lock on a guide's row, and a drag down either column that passes guide rows. Every other +/// command given a ruler guide's id refuses it (the guide commands take guides by index). +pub(crate) const TAKES_GUIDE_IDS: &[&str] = &["layer.setProps"]; + /// The non-empty `ids` given to a command whose params description names `ids` as a key (the /// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of /// one when no `ids` are given and the description takes `id` so diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2433923309..1e4629e448 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -264,6 +264,9 @@ pub struct Prefs { /// Character panel › Show Snap to Glyph Options: which guides View › Snap to Glyph gives /// (`view.snapToGlyph.options`; a preference group, see [`cmd::prefscmds::PREF_GROUPS`]). pub glyph_snap: vectorcraft_tools::GlyphSnap, + /// Pixel Snapping Options: whether View › Snap to Pixel snaps drawing, moving and scaling + /// (`view.snapToPixel.options`; a preference group, see [`cmd::prefscmds::PREF_GROUPS`]). + pub pixel_snap: vectorcraft_tools::PixelSnap, /// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored). /// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*` /// edit it. @@ -528,6 +531,7 @@ impl Default for Prefs { eyedropper: Default::default(), pathfinder: Default::default(), glyph_snap: Default::default(), + pixel_snap: Default::default(), flattener_presets: vec![], trace_presets: vec![], gradient_presets: vec![], diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index 08bc0f719d..8be87b5ab8 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -26,10 +26,11 @@ use crate::cmd::{CommandSpec, bad, command_specs}; use crate::{EngineError, Result}; /// The API level of the commands' parameters. -pub const API_VERSION: u32 = 3; +pub const API_VERSION: u32 = 4; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = + &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a), (4, 0x1be4_be72_fe16_54c4)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { @@ -311,12 +312,17 @@ mod tests { ("view.transparencyGrid", json!({"on": 1})), ("view.snapToGlyph.options", json!({"bounds": false, "angular": true})), ("view.snapToGlyph.options", json!({"bounds": null})), + ("view.snapToPixel.options", json!({"drawing": false, "scaling": true})), + ("view.snapToPixel.options", json!({"moving": null})), + ("view.snapToPixel.options", json!({"moving": "off"})), + ("view.snapToPixel.options", json!({"rotating": false})), ("view.capsLock", json!(null)), ("view.saved.new", json!(null)), ("view.saved.edit", json!(null)), ("view.saved.list", json!(null)), ("view.transparencyGrid", json!(null)), ("view.snapToGlyph.options", json!(null)), + ("view.snapToPixel.options", json!(null)), ("view.capsLock", json!([])), ("view.saved.new", json!(5)), ("view.transparencyGrid", json!("on")), @@ -325,7 +331,7 @@ mod tests { ("engine.apiReference", json!({"prefix": "view."})), ("engine.apiReference", json!([])), ]; - assert_eq!(schemas().len(), 7); + assert_eq!(schemas().len(), 8); for (id, schema) in schemas() { let written = schema.to_json(); check_schema(&written, id).unwrap_or_else(|e| panic!("{e}\n{written:#}")); @@ -423,6 +429,7 @@ mod tests { ("view.saved.list", "nothing"), ("view.transparencyGrid", "session"), ("view.snapToGlyph.options", "preferences"), + ("view.snapToPixel.options", "preferences"), ]; for (id, writes) in expected { let got = listed.iter().find(|(c, _)| *c == id).map(|(_, w)| w.clone()); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index ed5cd0f52d..1edeb84e93 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -823,6 +823,34 @@ impl Host { } } + /// The file and line that line `line` of the source with tag `tag` came from, as `$.line` and + /// `$.stack` give them: the run's script, a file it included or a file `$.evalFile` gave, in + /// this run or in an earlier run of its named engine, with the run's name standing for a script + /// given as text. Code that `eval` or `Function` compiled has no map and keeps its own line, + /// named `[eval]`; code whose map is gone, such as one the named engine let go to stay within + /// [`KEPT_MAP_BYTES`], is at line 0 of `[unknown]`. + fn source_place(&self, tag: u32, line: u32) -> (String, u32) { + if tag == vectorcraft_js::UNKNOWN_SOURCE { + return ("[eval]".to_string(), line); + } + let named = |map: &include::SourceMap, script: Option<&Arc>| { + let (index, file, line, _) = map.locate(line, 1); + match script { + Some(name) if index == 0 && file.is_empty() => (name.to_string(), line), + _ => (file.to_string(), line), + } + }; + let script = self.script_tag.get(); + let placed = match tag.checked_sub(script).filter(|_| script != vectorcraft_js::UNKNOWN_SOURCE) { + Some(0) => self.script_map.try_borrow().ok().map(|m| named(&m, Some(&self.report_name))), + Some(n) => self.eval_maps.try_borrow().ok().and_then(|maps| maps.get(n as usize - 1).map(|m| named(m, None))), + None => { + self.kept_maps.as_ref().and_then(|k| k.try_borrow().ok()).and_then(|k| k.get(tag).map(|kept| named(&kept.map, kept.script.as_ref()))) + } + }; + placed.unwrap_or_else(|| ("[unknown]".to_string(), 0)) + } + /// `$.fileName` read by the code at `site`: the file that code is in, by the source map of the /// source it came from (this run's script, a file `$.evalFile` gave, or code an earlier run of /// the named engine compiled), so a function keeps the file it was defined in. Code from a file @@ -1891,6 +1919,13 @@ impl Session { Ok(expanded) => { *host.script_map.borrow_mut() = expanded.map; realm.set_strict_writes(expanded.strict == Some(true)); + if fresh { + let l = link.clone(); + realm.set_source_locator(Rc::new(move |tag, line| match current(&l) { + Ok(h) => h.source_place(tag, line), + Err(_) => ("[unknown]".to_string(), 0), + })); + } (if fresh { realm.add_script_extensions() } else { Ok(()) }) .and_then(|()| if fresh { realm.define_host_object("vc", vc_object(&link)) } else { Ok(()) }) .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 2c59c5630a..d6af60fee1 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,4 +1,4 @@ -use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only, waits_for_tool_edit}; +use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, TAKES_GUIDE_IDS, given_ids, strip_replay_only, waits_for_tool_edit}; use super::doc_state::DocState; use super::error::{EngineError, Result}; use super::history::NamedStep; diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 0d472ef81d..297322fda0 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -404,11 +404,18 @@ impl Session { true } + /// Close the document in tab `index` (`file.close`). Closing a tab other than the current one + /// keeps the current document current, and its tool keeps its state: no document is switched + /// to. Closing the current document makes the tab now at `index` current, or the last tab when + /// it was the last one. False, changing nothing, when `index` is past the last document. pub fn close_document(&mut self, index: usize) -> bool { if index >= self.docs.len() { return false; } - self.reset_tool_for_doc_switch(); + let front = self.active().map(|d| d.uid).filter(|_| self.active != Some(index)); + if front.is_none() { + self.reset_tool_for_doc_switch(); + } // Closed (saved or discarded): nothing left to recover. let uid = self.docs[index].uid; cmd::recovery::forget(self, uid); @@ -416,6 +423,10 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } + if let Some(uid) = front { + self.active = self.docs.iter().position(|d| d.uid == uid); + return true; + } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; self.sync_front_composite_fonts(); self.sync_front_profiles(); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index d6e85cbdd9..3bc5c72378 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,6 +1,6 @@ use super::{ - CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, - strip_replay_only, waits_for_tool_edit, + CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, TAKES_GUIDE_IDS, Value, cmd, find_command, + given_ids, guard, strip_replay_only, waits_for_tool_edit, }; /// A command's result `r` with the warnings of the previews that settling an interaction applied @@ -115,7 +115,7 @@ impl Session { if id == "effect.refreshDeferred" { cmd::given_object_ids(params, id)?; } else { - cmd::given_objects(self, params, id)?; + cmd::given_nodes(self, params, id, TAKES_GUIDE_IDS.contains(&id))?; } } if SELECTS_GIVEN_IDS.contains(&id) { @@ -134,7 +134,8 @@ impl Session { // whichever tool is chosen. let given = given_ids(spec.params, params); // The guide commands take ruler guides by index; an object command given one's id is refused. - if let (Some(ids), Some(st)) = (&given, self.active()) + if !TAKES_GUIDE_IDS.contains(&id) + && let (Some(ids), Some(st)) = (&given, self.active()) && let Some(guide) = ids.iter().find(|i| st.doc.node(**i).is_some_and(|n| n.is_ruler_guide())) { let value = guide.0; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2352776651..c260b42d90 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -47,6 +47,7 @@ mod dashalign; mod dcs; mod distort; mod docsetup; +mod document_close; mod document_move; mod draw2; mod dynamic_symbols; @@ -4033,6 +4034,38 @@ fn snap_to_pixel_rounds_drawing_and_moves() { assert_eq!((b.x0, b.y0), (17.0, 16.0)); } +/// Pixel Snapping Options (`view.snapToPixel.options`): with While Drawing off a rectangle drawn +/// with Snap to Pixel on keeps its fractional corners while a move still lands on whole pixels, and +/// with While Moving off a move keeps its fractional offset while drawing snaps again. +#[test] +fn pixel_snapping_options_choose_the_edits_snap_to_pixel_rounds() { + let mut s = session(); + let v = ViewInfo { snap_to_pixel: true, smart_guides: false, snap_to_point: false, ..Default::default() }; + let drag = |s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)| { + s.select_tool(tool, v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap(); + }; + let bounds = |s: &Session, id| { + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0, b.x1, b.y1) + }; + s.execute("view.snapToPixel.options", &json!({"drawing": false})).unwrap(); + drag(&mut s, "rectangle", (10.25, 10.5), (60.5, 40.25)); + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(bounds(&s, id), (10.25, 10.5, 60.5, 40.25), "drawing does not snap"); + drag(&mut s, "selection", (30.0, 30.0), (37.5, 34.75)); + assert_eq!(bounds(&s, id).0, 18.0, "moving still snaps the left edge"); + assert_eq!(bounds(&s, id).1, 15.0, "moving still snaps the top edge"); + s.execute("view.snapToPixel.options", &json!({"drawing": true, "moving": false})).unwrap(); + drag(&mut s, "selection", (40.0, 30.0), (45.25, 33.5)); + assert_eq!((bounds(&s, id).0, bounds(&s, id).1), (23.25, 18.5), "moving does not snap"); + drag(&mut s, "rectangle", (100.25, 100.5), (140.5, 130.75)); + let drawn = s.doc().unwrap().selection.objects[0]; + assert_eq!(bounds(&s, drawn), (100.0, 101.0, 141.0, 131.0), "drawing snaps again"); +} + #[test] fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() { let mut s = session(); diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs index b350341767..0cdc3312ff 100644 --- a/crates/engine/src/tests/arrowheads.rs +++ b/crates/engine/src/tests/arrowheads.rs @@ -284,6 +284,87 @@ fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); } +/// Automation roots that grant reading `read` alone. +fn read_roots(read: &[&std::path::Path]) -> Arc { + crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap() +} + +#[test] +fn an_agent_outside_the_arrowheads_files_folder_sees_its_file_name_alone_and_cannot_reload_it() { + let dir = folder("roots-list"); + let (prefs, other) = (dir.join("prefs"), dir.join("other")); + std::fs::create_dir_all(&prefs).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + let file = prefs.join(arrowheads::FILE_NAME); + std::fs::write(&file, b"not a drawing").unwrap(); + let (file, prefs_text) = (file.to_string_lossy().to_string(), prefs.to_string_lossy().to_string()); + let mut s = Session::new(); + s.attach_arrowheads_file(file.clone()); + let (outside, inside) = (read_roots(&[&other]), read_roots(&[&prefs])); + let list = crate::file_access::confine(Some(&outside), || s.execute("stroke.arrowheads.list", &json!({}))).unwrap(); + assert_eq!(list["file"], serde_json::Value::Null, "{list}"); + let error = list["error"].as_str().unwrap(); + assert!(error.starts_with(arrowheads::FILE_NAME) && !error.contains(&prefs_text), "{error}"); + let list = crate::file_access::confine(Some(&inside), || s.execute("stroke.arrowheads.list", &json!({}))).unwrap(); + assert_eq!(list["file"].as_str(), Some(file.as_str())); + assert!(list["error"].as_str().unwrap().starts_with(&file), "{list}"); + // A good file now: an agent outside the folder cannot have it read. + std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap(); + let e = crate::file_access::confine(Some(&outside), || s.execute("stroke.arrowheads.reload", &json!({}))).unwrap_err().to_string(); + assert!(e.contains("outside the read root") && e.ends_with(arrowheads::FILE_NAME) && !e.contains(&prefs_text), "{e}"); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty() && list["error"].is_string(), "not read again: {list}"); + let r = crate::file_access::confine(Some(&inside), || s.execute("stroke.arrowheads.reload", &json!({}))).unwrap(); + assert_eq!(r["count"], 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_agent_edits_the_arrowheads_file_only_inside_its_read_roots() { + let dir = folder("roots-edit"); + let (prefs, other) = (dir.join("prefs"), dir.join("other")); + std::fs::create_dir_all(&other).unwrap(); + let file = prefs.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let prefs_text = prefs.to_string_lossy().to_string(); + let (mut s, _) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + let line_doc = s.doc().unwrap().uid; + let outside = read_roots(&[&other]); + let edit = |s: &mut Session, roots: &Arc| { + crate::file_access::confine(Some(roots), || s.execute("stroke.arrowheads.edit", &json!({}))) + }; + // Refused before the folder or the file is made, or anything is opened. + let e = edit(&mut s, &outside).unwrap_err().to_string(); + assert!(e.ends_with(arrowheads::FILE_NAME) && !e.contains(&prefs_text), "{e}"); + assert!(!prefs.exists(), "no folder made"); + assert_eq!((s.documents().len(), s.doc().unwrap().uid), (1, line_doc)); + // From the Stroke panel menu (no roots), the file is made and opened. + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true); + assert_eq!(s.documents().len(), 2); + // Open already: still not brought to the front for an agent outside the folder. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert!(edit(&mut s, &outside).is_err()); + assert_eq!((s.documents().len(), s.doc().unwrap().uid), (2, line_doc)); + // Inside the read roots: opened under them, with its path. + let inside = read_roots(&[&prefs]); + let mut agent = Session::new(); + agent.attach_arrowheads_file(file.clone()); + let r = edit(&mut agent, &inside).unwrap(); + assert_eq!((r["path"].as_str(), &r["created"]), (Some(file.as_str()), &json!(false))); + let st = agent.doc().unwrap(); + assert_eq!(st.path.as_deref(), Some(file.as_str())); + assert!(st.opened_by.as_ref().is_some_and(|r| Arc::ptr_eq(r, &inside)), "opened under the agent's roots"); + // A missing file inside the roots is still made first. + let fresh = dir.join("fresh").join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let mut agent = Session::new(); + agent.attach_arrowheads_file(fresh.clone()); + let r = edit(&mut agent, &read_roots(&[&dir])).unwrap(); + assert_eq!(r["created"], true); + assert!(std::path::Path::new(&fresh).is_file()); + assert_eq!(agent.doc().unwrap().path.as_deref(), Some(fresh.as_str())); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() { let dir = folder("saved"); diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 93f531d1e8..05fbf1a72e 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -542,6 +542,47 @@ fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() { assert_eq!(spec(&s, g).1.color_shift, 0.0); } +/// A blend made without a spine has the Appearance widget alone: a drag of it with the Selection +/// tool or the Blend tool changes the color shift in one undo step, and there is no Easing widget +/// to drag. +#[test] +fn dragging_the_appearance_widget_of_a_blend_without_a_spine_is_one_undo_step() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "createSpine": "never", "orientation": "path"})).unwrap()); + assert!(spec(&s, g).1.no_spine); + let v = ViewInfo::default(); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); + }; + for (n, tool) in [(1.0, "selection"), (2.0, "blend")] { + s.select_tool(tool, v).unwrap(); + sel(&mut s, &[g]); + let d0 = depth(&s); + // The Appearance widget sits 16 px above the halfway point (120, 20) of the line between + // the key centers. + ptr(&mut s, PointerKind::Down, 120.0, 4.0); + ptr(&mut s, PointerKind::Drag, 135.0, 4.0); + ptr(&mut s, PointerKind::Up, 135.0, 4.0); + let sp = spec(&s, g).1; + assert_eq!((sp.color_shift, sp.ease.ramp, depth(&s)), (15.0 * n, 0.0, d0 + 1), "{tool}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g], "{tool}: the blend stays selected"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.color_shift, 15.0, "each drag is its own undo step"); + // 16 px below there is no widget: a drag there leaves the easing and the color shift alone. + s.select_tool("selection", v).unwrap(); + sel(&mut s, &[g]); + let before = spec(&s, g).1; + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 150.0, 36.0); + ptr(&mut s, PointerKind::Up, 150.0, 36.0); + let after = spec(&s, g).1; + assert_eq!((after.ease, after.color_shift), (before.ease, before.color_shift), "no Easing widget"); +} + #[test] fn native_blend_easing_values_clamp_and_non_finite_become_default() { for (bad, ramp, shift) in [ diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 21cbf678eb..6eeace1073 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -840,7 +840,7 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { }), ]; let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first"); + let dir = vectorcraft_testkit::TempDir::new("shape-builder-merge-first"); for (what, then) in next { let mut s = session(); if what == "Select All Instances" { diff --git a/crates/engine/src/tests/cancel.rs b/crates/engine/src/tests/cancel.rs index 8237707a10..9a044ead89 100644 --- a/crates/engine/src/tests/cancel.rs +++ b/crates/engine/src/tests/cancel.rs @@ -22,7 +22,8 @@ fn revision(s: &Session) -> u64 { #[test] fn a_pdf_export_cancelled_between_pages_stops_writes_nothing_and_changes_nothing() { - let path = vectorcraft_testkit::temp_dir("cancel-pdf").join("out.pdf"); + let dir = vectorcraft_testkit::TempDir::new("cancel-pdf"); + let path = dir.join("out.pdf"); std::fs::remove_file(&path).ok(); let mut s = session(5); let before = (revision(&s), s.doc().unwrap().history.undo.len()); @@ -66,7 +67,7 @@ fn a_cancelled_flattening_export_stops_before_the_pages() { #[test] fn raster_exports_and_svg_stop_when_cancelled_too() { - let dir = vectorcraft_testkit::temp_dir("cancel-formats"); + let dir = vectorcraft_testkit::TempDir::new("cancel-formats"); let mut s = session(3); let token = CancelToken::new(); token.cancel(); diff --git a/crates/engine/src/tests/compare.rs b/crates/engine/src/tests/compare.rs index 3bd4446d61..3a6c66e2f5 100644 --- a/crates/engine/src/tests/compare.rs +++ b/crates/engine/src/tests/compare.rs @@ -36,7 +36,7 @@ fn error(s: &mut Session, p: Value) -> String { #[test] fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() { - let dir = vectorcraft_testkit::temp_dir("compare-moved"); + let dir = vectorcraft_testkit::TempDir::new("compare-moved"); let png = path_in(&dir, "art.png"); let (mut s, id) = session(); export(&mut s, &png); @@ -54,12 +54,11 @@ fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() { assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}"); // Above every difference, nothing is over. assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0)); - let _ = std::fs::remove_dir_all(dir); } #[test] fn the_image_and_the_render_are_flattened_on_the_background() { - let dir = vectorcraft_testkit::temp_dir("compare-background"); + let dir = vectorcraft_testkit::TempDir::new("compare-background"); let png = path_in(&dir, "clear.png"); // Transparent everywhere, with red in the color channels that the alpha hides. image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap(); @@ -81,12 +80,11 @@ fn the_image_and_the_render_are_flattened_on_the_background() { assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}"); // A threshold just above 128/255 leaves the gray differences out. assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0)); - let _ = std::fs::remove_dir_all(dir); } #[test] fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() { - let dir = vectorcraft_testkit::temp_dir("compare-size"); + let dir = vectorcraft_testkit::TempDir::new("compare-size"); let (mut s, _) = session(); let square = path_in(&dir, "square.png"); image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap(); @@ -96,12 +94,11 @@ fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() { image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap(); let r = compare(&mut s, json!({"path": taller})); assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}"); - let _ = std::fs::remove_dir_all(dir); } #[test] fn an_export_whose_height_was_rounded_compares_equal() { - let dir = vectorcraft_testkit::temp_dir("compare-rounded"); + let dir = vectorcraft_testkit::TempDir::new("compare-rounded"); let png = path_in(&dir, "art.png"); // 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41. let mut s = Session::new(); @@ -112,7 +109,6 @@ fn an_export_whose_height_was_rounded_compares_equal() { export(&mut s, &png); let r = compare(&mut s, json!({"path": png})); assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08})); - let _ = std::fs::remove_dir_all(dir); } #[test] @@ -151,7 +147,7 @@ fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() { #[test] fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() { - let dir = vectorcraft_testkit::temp_dir("compare-warnings"); + let dir = vectorcraft_testkit::TempDir::new("compare-warnings"); let png = path_in(&dir, "art.png"); let (mut s, _) = session(); export(&mut s, &png); @@ -167,12 +163,11 @@ fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does( assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}"); assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}"); assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}"); - let _ = std::fs::remove_dir_all(dir); } #[test] fn diff_path_writes_each_pixels_differences() { - let dir = vectorcraft_testkit::temp_dir("compare-diff"); + let dir = vectorcraft_testkit::TempDir::new("compare-diff"); let png = path_in(&dir, "art.png"); let diff = path_in(&dir, "diff.png"); let (mut s, id) = session(); @@ -188,12 +183,11 @@ fn diff_path_writes_each_pixels_differences() { } let changed = img.pixels().filter(|p| p.0[0] != 0).count(); assert_eq!(changed as u64, r["over"].as_u64().unwrap()); - let _ = std::fs::remove_dir_all(dir); } #[test] fn missing_unreadable_and_malformed_inputs_are_errors() { - let dir = vectorcraft_testkit::temp_dir("compare-errors"); + let dir = vectorcraft_testkit::TempDir::new("compare-errors"); let (mut s, _) = session(); let png = path_in(&dir, "art.png"); export(&mut s, &png); @@ -216,14 +210,13 @@ fn missing_unreadable_and_malformed_inputs_are_errors() { let e = error(&mut s, p.clone()); assert!(e.contains(want), "{p}: {e}"); } - let _ = std::fs::remove_dir_all(dir); } /// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip /// each, times 5 planes) is refused as too large to draw, without a panic. #[test] fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() { - let dir = vectorcraft_testkit::temp_dir("compare-strips"); + let dir = vectorcraft_testkit::TempDir::new("compare-strips"); let (mut s, _) = session(); let plane = |v: u16| vec![v; 6]; let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)]; @@ -247,12 +240,11 @@ fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() { std::fs::write(&tif, &file).unwrap(); let e = error(&mut s, json!({"path": tif})); assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw"); - let _ = std::fs::remove_dir_all(dir); } #[test] fn the_image_and_the_difference_image_stay_inside_the_automation_roots() { - let dir = vectorcraft_testkit::temp_dir("compare-roots"); + let dir = vectorcraft_testkit::TempDir::new("compare-roots"); for d in ["in", "out", "elsewhere"] { std::fs::create_dir_all(dir.join(d)).unwrap(); } @@ -272,5 +264,4 @@ fn the_image_and_the_difference_image_stay_inside_the_automation_roots() { assert!(!std::path::Path::new(&stray).exists()); let kept = path_in(&dir.join("out"), "diff.png"); assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept)); - let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/tests/containers.rs b/crates/engine/src/tests/containers.rs index 437f932724..42cebea2ad 100644 --- a/crates/engine/src/tests/containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -312,7 +312,7 @@ fn an_images_stroke_with_outline_object_follows_its_opaque_area() { run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 0})); assert_eq!([shade(&s, 61, 100), shade(&s, 64, 100)], ["white", "black"]); // Relinked to an opaque image, the stroke follows the frame 3 pt out: it shows from 43 to 50. - let dir = vectorcraft_testkit::temp_dir("outline-object-relink"); + let dir = vectorcraft_testkit::TempDir::new("outline-object-relink"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("opaque.png"); let mut png = vec![]; diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs index 1ce5b09e41..39fb6a5bc2 100644 --- a/crates/engine/src/tests/css.rs +++ b/crates/engine/src/tests/css.rs @@ -57,6 +57,28 @@ fn type_gives_font_properties_and_gradients_css_gradients() { assert!(text.contains("background-image: linear-gradient(90deg, "), "{text}"); } +/// CSS gradients mix linearly: a gradient mixed around the hue wheel is written through the stops +/// on its way around the wheel, so red to blue lists green one way and magenta the other. +#[test] +fn gradients_mixed_around_the_hue_wheel_give_css_the_stops_on_their_way() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50})); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + let gradient = |s: &mut Session, method: &str| { + run(s, "paint.setFill", json!({"gradient": {"kind": "linear", "method": method, "stops": stops}})); + let text = css(&run(s, "css.selection", json!({}))).to_string(); + text.lines().find(|l| l.contains("linear-gradient(")).unwrap_or_else(|| panic!("a gradient in {text}")).to_string() + }; + let classic = gradient(&mut s, "classic"); + assert_eq!(classic.matches('%').count(), 2, "{classic}"); + for (method, middle, other) in [("hue-counterclockwise", "#00ff00 50%", "#ff00ff"), ("hue-clockwise", "#ff00ff 50%", "#00ff00")] { + let line = gradient(&mut s, method); + assert!(line.matches('%').count() > 2, "{method}: {line}"); + assert!(line.contains("#ff0000 0%") && line.contains("#0000ff 100%"), "{method}: {line}"); + assert!(line.contains(middle) && !line.contains(other), "{method}: {line}"); + } +} + #[test] fn generate_covers_the_document_and_an_empty_selection_has_none() { let mut s = session(); diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index c801401de0..b780f14c09 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -3,8 +3,6 @@ //! the Links panel's Show DCS Transparency Interactions (`links.list` with `dcsTransparency`). //! The files are made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them. -use std::path::PathBuf; - use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; @@ -17,12 +15,9 @@ fn session() -> Session { s } -/// A fresh scratch folder for one test. -fn temp_dir(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("vc-dcs-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d +/// A fresh scratch folder for one test, removed when dropped. +fn temp_dir(name: &str) -> vectorcraft_testkit::TempDir { + vectorcraft_testkit::TempDir::new(&format!("dcs-{name}")) } /// A plate program: one 2 × 2 grayscale image (`gray`, row by row) on a 20 × 10 pt page. @@ -326,6 +321,31 @@ fn update_link_takes_the_files_clipping_path_into_symbols_too() { assert_eq!(symbol_clip_width(&s, "Pic"), None); } +#[test] +fn update_and_relink_by_id_keep_the_clipping_path_of_a_dcs_image_in_a_symbol() { + let dir = temp_dir("symbol-clip-by-id"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image is listed"); + let id = row["id"].as_u64().unwrap(); + assert_eq!(row["format"], "DCS", "{list}"); + let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path"); + // The composite now drawn through the left quarter of the page (same length). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + assert_eq!(s.execute("links.update", &json!({ "ids": [id] })).unwrap()["updated"], json!([id])); + let after = symbol_clip_width(&s, "Pic").expect("still clipped"); + assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}"); + // Relinked by its id to a plain image: the DCS file's clipping path goes. + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + s.execute("links.relink", &json!({ "ids": [id], "path": png.to_string_lossy() })).unwrap(); + assert_eq!(symbol_clip_width(&s, "Pic"), None); +} + #[test] fn a_file_that_lost_its_clipping_path_unclips_the_image() { let dir = temp_dir("update-lost-clip"); diff --git a/crates/engine/src/tests/document_close.rs b/crates/engine/src/tests/document_close.rs new file mode 100644 index 0000000000..a1b106d365 --- /dev/null +++ b/crates/engine/src/tests/document_close.rs @@ -0,0 +1,91 @@ +//! `file.close {index}`: closing a document tab other than the current one keeps the current +//! document current, and closing the current one makes its neighbor current. + +use serde_json::json; + +use crate::*; + +/// A session with documents A, B, C and D, in that order, D current. +fn four() -> Session { + let mut s = Session::new(); + for name in ["A", "B", "C", "D"] { + s.execute("file.new", &json!({"name": name, "created": null})).unwrap(); + } + s +} + +fn order(s: &Session) -> String { + s.documents().iter().map(DocState::title).collect::>().join("") +} + +fn current(s: &Session) -> String { + s.active().map(DocState::title).unwrap_or_default() +} + +fn close(s: &mut Session, index: usize) { + s.execute("file.close", &json!({"index": index})).unwrap_or_else(|e| panic!("file.close {index}: {e}")); +} + +#[test] +fn closing_a_tab_before_the_current_one_keeps_the_current_document() { + let mut s = four(); + close(&mut s, 0); + assert_eq!((order(&s), current(&s)), ("BCD".into(), "D".into())); + assert_eq!(s.active_index(), Some(2)); + assert!(s.set_active(1)); + close(&mut s, 0); + assert_eq!((order(&s), current(&s)), ("CD".into(), "C".into())); + assert_eq!(s.active_index(), Some(0)); +} + +#[test] +fn closing_a_tab_after_the_current_one_keeps_the_current_document() { + let mut s = four(); + assert!(s.set_active(1)); + close(&mut s, 3); + assert_eq!((order(&s), current(&s)), ("ABC".into(), "B".into())); + close(&mut s, 2); + assert_eq!((order(&s), current(&s)), ("AB".into(), "B".into())); + assert_eq!(s.active_index(), Some(1)); +} + +#[test] +fn closing_the_current_tab_makes_its_neighbor_current() { + let mut s = four(); + assert!(s.set_active(1)); + close(&mut s, 1); + assert_eq!((order(&s), current(&s)), ("ACD".into(), "C".into()), "the tab that took its place"); + assert!(s.set_active(2)); + close(&mut s, 2); + assert_eq!((order(&s), current(&s)), ("AC".into(), "C".into()), "the last tab: the one before it"); + s.execute("file.close", &json!({})).unwrap(); + assert_eq!((order(&s), current(&s)), ("A".into(), "A".into()), "without an index, the current one closes"); + close(&mut s, 0); + assert!(s.active().is_none()); + assert!(s.execute("file.close", &json!({"index": 0})).is_err(), "no document left"); +} + +#[test] +fn closing_another_tab_keeps_the_tool_and_the_documents_edits() { + let mut s = four(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let resets = s.tool_resets; + let nodes = s.doc().unwrap().doc.node_count(); + let history = super::history(&s); + close(&mut s, 1); + assert_eq!(s.tool_resets, resets, "no document was switched to, so the tool is not reset"); + assert_eq!(super::history(&s), history); + assert_eq!(s.doc().unwrap().doc.node_count(), nodes); + assert!(s.doc().unwrap().is_dirty(), "the document keeps its unsaved edit"); + close(&mut s, 2); + assert_ne!(s.tool_resets, resets, "closing the current document resets the tool"); +} + +#[test] +fn a_bad_index_closes_nothing() { + let mut s = four(); + assert!(s.set_active(2)); + assert!(s.execute("file.close", &json!({"index": 4})).is_err()); + assert!(!s.close_document(4)); + assert_eq!((order(&s), current(&s)), ("ABCD".into(), "C".into())); +} diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index a9035de1da..f29df7ff23 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -4717,6 +4717,12 @@ fn blob_anchors(s: &mut Session, fidelity: f64) -> usize { /// [`blob_anchors`] with the blob scaled by `scale`: drawn the same on screen zoomed in 1 / `scale` /// times. fn scaled_blob_anchors(s: &mut Session, fidelity: f64, scale: f64) -> usize { + blob_anchors_through(s, "path.freehand", &json!({}), Some(fidelity), scale) +} + +/// [`scaled_blob_anchors`] drawn with `command` and the params in `extra`, at `fidelity` (with none, +/// the command's default). +fn blob_anchors_through(s: &mut Session, command: &str, extra: &serde_json::Value, fidelity: Option, scale: f64) -> usize { let pts: Vec<_> = (0..1200) .map(|i| { let a = i as f64 / 1200.0 * std::f64::consts::TAU; @@ -4724,7 +4730,14 @@ fn scaled_blob_anchors(s: &mut Session, fidelity: f64, scale: f64) -> usize { json!([(400.0 + r * a.cos()) * scale, (300.0 + r * a.sin()) * scale]) }) .collect(); - let r = s.execute("path.freehand", &json!({"points": pts, "closed": true, "fidelity": fidelity, "keepSelected": false})).unwrap(); + let mut p = json!({"points": pts, "closed": true, "keepSelected": false}); + if let Some(f) = fidelity { + p["fidelity"] = json!(f); + } + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let r = s.execute(command, &p).unwrap(); path(s, NodeId(r["id"].as_u64().unwrap())).subpaths.iter().map(|sp| sp.anchors.len()).sum() } @@ -4736,13 +4749,11 @@ fn pencil_fidelity_goes_down_to_a_hundredth_and_lower_keeps_more() { assert!(counts.windows(2).all(|w| w[0] <= w[1]), "{counts:?}"); // Below the old floor of 0.05 the stroke keeps more of itself. assert!(counts[4] > counts[2], "{counts:?}"); - // Below the floor, the lowest Fidelity (0.01 px) at the highest zoom (64000 %), is read as the - // floor (#5729). + // The floor is the lowest Fidelity (0.01 px) at the highest zoom (64000 %) (#5729); below it, + // `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`. let floor = vectorcraft_tools::draw2::MIN_FIDELITY / 640.0; let at_floor = blob_anchors(&mut s, floor); assert!(at_floor >= counts[4], "{counts:?}"); - assert_eq!(blob_anchors(&mut s, floor / 10.0), at_floor); - assert_eq!(blob_anchors(&mut s, 0.0), at_floor); } /// #5729: the tools send Fidelity / zoom, so a stroke drawn the same on screen zoomed in keeps as @@ -4758,6 +4769,89 @@ fn pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_in() { } } +/// The tools send Fidelity / zoom up to the highest Fidelity (100 px) at the lowest zoom (3.13 %): +/// a stroke drawn the same on screen zoomed out keeps as many anchors as at 100 %, as a Pencil or a +/// Paintbrush stroke and through `brush.freehand` with a brush. A tolerance above +/// `MAX_FIT_TOLERANCE` is an error, and a stroke given no Fidelity is fitted at 1.5 pt. +#[test] +fn pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out() { + let mut s = session(); + let max = vectorcraft_tools::draw2::MAX_FIT_TOLERANCE; + // The blob six times the size, so that 100 px still keeps some of its waves. + let big = 6.0; + for (command, extra) in [ + ("path.freehand", json!({})), + ("path.freehand", json!({"style": "brush"})), + ("brush.freehand", json!({"style": "brush", "brush": "3 pt. Round"})), + ] { + for fidelity in [20.0, 100.0] { + let want = blob_anchors_through(&mut s, command, &extra, Some(fidelity), big); + for zoom in [0.2, 0.0313] { + let got = blob_anchors_through(&mut s, command, &extra, Some(fidelity / zoom), big / zoom); + assert_eq!(got, want, "{command} {extra}: {fidelity} px at {zoom}x"); + // Fitted at 100 pt, the stroke on screen would keep the detail of a finer Fidelity. + if fidelity / zoom > 100.0 { + let at_100 = blob_anchors_through(&mut s, command, &extra, Some(100.0), big / zoom); + assert!(at_100 > want, "{command} {extra}: {fidelity} px at {zoom}x, 100 pt keeps {at_100} of {want}"); + } + } + } + let mut p = json!({"points": [[0, 0], [100, 50], [200, 0]], "fidelity": max * 10.0}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let err = s.execute(command, &p).unwrap_err().to_string(); + assert!(err.contains("`fidelity` must be from"), "{command} {extra}: {err}"); + let default = blob_anchors_through(&mut s, command, &extra, None, 1.0); + assert_eq!(default, blob_anchors_through(&mut s, command, &extra, Some(1.5), 1.0), "{command} {extra}"); + } +} + +/// `path.freehand` and `brush.freehand` take a `fidelity` from `MIN_FIT_TOLERANCE` (the lowest +/// Fidelity at the highest zoom) to `MAX_FIT_TOLERANCE` (the highest at the lowest zoom), both ends +/// included, and reject a value just outside either end, a tenth of the lowest, 0, a negative one or +/// a huge one, leaving the document as it was. +#[test] +fn pencil_takes_fidelity_within_its_range_and_rejects_the_rest() { + let (lo, hi) = (vectorcraft_tools::draw2::MIN_FIT_TOLERANCE, vectorcraft_tools::draw2::MAX_FIT_TOLERANCE); + let points = json!([[0, 0], [100, 50], [200, 0], [300, 50]]); + for (command, extra) in [("path.freehand", json!({})), ("brush.freehand", json!({"style": "brush", "brush": "3 pt. Round"}))] { + let run = |s: &mut Session, fidelity: f64| { + let mut p = json!({"points": points, "fidelity": fidelity}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + s.execute(command, &p) + }; + let mut s = session(); + for fidelity in [100.0 / 0.0313, hi, lo, 0.01 / 640.0] { + let r = run(&mut s, fidelity); + assert!(r.as_ref().is_ok_and(|r| r["id"].is_u64()), "{command}: {fidelity} pt: {r:?}"); + } + let before = s.doc().unwrap().doc.clone(); + for fidelity in [f64::from_bits(hi.to_bits() + 1), f64::from_bits(lo.to_bits() - 1), lo / 10.0, 0.0, -1.5, 1e300] { + let err = run(&mut s, fidelity).unwrap_err().to_string(); + assert!(err.contains("`fidelity` must be from"), "{command}: {fidelity} pt: {err}"); + } + assert_eq!(s.doc().unwrap().doc, before, "{command}"); + } +} + +/// A stroke that continues an open path closes it when it ends within four Fidelities of the path's +/// other end, at most 400 pt away: at the coarsest Fidelity one ending 1000 pt away leaves it open, +/// and one ending 300 pt away closes it. +#[test] +fn pencil_extend_closes_within_400_pt_at_any_fidelity() { + let mut s = session(); + let max = vectorcraft_tools::draw2::MAX_FIT_TOLERANCE; + for (end, closed) in [([0, 1000], false), ([0, 300], true)] { + let l = line(&mut s, 0.0, 0.0, 2000.0, 0.0); + let points = json!([[2000, 0], [2000, 1000], [1000, 1500], end]); + s.execute("path.freehand", &json!({"points": points, "fidelity": max, "extend": {"id": l.0}, "keepSelected": false})).unwrap(); + assert_eq!(path(&s, l).subpaths[0].closed, closed, "ending at {end:?}"); + } +} + /// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never /// crash, and what they draw has finite anchors. #[test] diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs index 8bef8733b5..52a8ff08a3 100644 --- a/crates/engine/src/tests/fontfiles.rs +++ b/crates/engine/src/tests/fontfiles.rs @@ -14,12 +14,12 @@ use crate::cmd::findfiles::{self, Limits, Rules, Visitor}; use crate::cmd::fontfiles::{self, FontSearch, MAX_FONT_FILE, Sought, copy_into}; use crate::{EngineError, Session}; -/// A fresh temporary folder (canonical, as a search reports it). -fn folder(tag: &str) -> PathBuf { - let dir = vectorcraft_testkit::temp_dir(&format!("fontfiles-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - findfiles::plain(std::fs::canonicalize(&dir).unwrap()) +/// A fresh temporary folder, removed when the first part is dropped, and its path (canonical, as a +/// search reports it). +fn folder(tag: &str) -> (vectorcraft_testkit::TempDir, PathBuf) { + let dir = vectorcraft_testkit::TempDir::new(&format!("fontfiles-{tag}")); + let path = findfiles::plain(std::fs::canonicalize(&*dir).unwrap()); + (dir, path) } /// A session whose folder searches may go anywhere in `root`, on two threads. @@ -103,7 +103,7 @@ fn missing_fonts_are_listed_symbols_included() { /// listed finds the other fonts' files. #[test] fn an_overlong_font_name_leaves_the_other_fonts_searchable() { - let root = folder("long-name"); + let (_dir, root) = folder("long-name"); std::fs::write(root.join("Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); let mut s = session_for(&root); s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); @@ -120,7 +120,7 @@ fn an_overlong_font_name_leaves_the_other_fonts_searchable() { /// apps' folders, such as the attachments a mail app keeps in the home folder's Library. #[test] fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() { - let root = folder("next-to"); + let (_dir, root) = folder("next-to"); let home = root.join("Users/me"); let mut s = Session::new(); s.search_rules = Some(Rules { home: Some(home.clone()), ..Rules::default() }); @@ -140,7 +140,7 @@ fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() { #[cfg(unix)] #[test] fn the_fonts_folder_next_to_a_document_is_an_absolute_path() { - let root = folder("relative"); + let (_dir, root) = folder("relative"); std::fs::create_dir_all(root.join("pkg/Fonts")).unwrap(); let cwd = std::env::current_dir().unwrap(); let up = "../".repeat(cwd.components().count().saturating_sub(1)); @@ -160,7 +160,7 @@ fn the_fonts_folder_next_to_a_document_is_an_absolute_path() { #[test] fn a_search_finds_a_missing_fonts_file_by_its_names() { - let root = folder("find"); + let (_dir, root) = folder("find"); std::fs::create_dir_all(root.join("x/y")).unwrap(); std::fs::write(root.join("x/y/Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); std::fs::write(root.join("other.ttf"), bundled("SourceSerif4-Regular.ttf")).unwrap(); @@ -199,7 +199,7 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() { /// when they are inside them. #[test] fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { - let root = folder("read-roots"); + let (_dir, root) = folder("read-roots"); let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3"); let (home, wide) = (root.join("home"), root.join("lrf-wide")); for dir in [&home, &wide] { @@ -228,12 +228,11 @@ fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide"))); let r = ask(&mut s, &narrow, json!({})); assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}"); - let _ = std::fs::remove_dir_all(&root); } #[test] fn junk_search_params_never_fail_internally() { - let root = folder("junk"); + let (_dir, root) = folder("junk"); let mut s = session_for(&root); s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); text(&mut s, 20.0, "No Such Font Family", "Regular"); @@ -297,7 +296,7 @@ impl Visitor for Blocked { #[test] fn stop_ends_only_the_search_it_names() { - let root = folder("stop"); + let (_dir, root) = folder("stop"); std::fs::write(root.join("a.ttf"), b"font").unwrap(); let mut s = session_for(&root); let (tx, rx) = mpsc::channel(); @@ -330,7 +329,7 @@ fn adding_fonts_needs_vectorcraft_s_fonts_folder() { #[test] fn copying_never_replaces_a_file() { - let root = folder("copy"); + let (_dir, root) = folder("copy"); let (src, dest) = (root.join("src"), root.join("dest")); std::fs::create_dir_all(&src).unwrap(); std::fs::create_dir_all(&dest).unwrap(); @@ -376,12 +375,11 @@ fn copying_never_replaces_a_file() { assert_eq!(reasons[..2], ["larger than 256 MB", "not a file"]); assert!(r.copied.is_empty() && reasons.len() == 3, "{r:?}"); assert!(!dest.join("huge.otf").exists() && !dest.join("folder.otf").exists()); - let _ = std::fs::remove_dir_all(&root); } #[test] fn only_font_files_are_copied() { - let root = folder("allowed"); + let (_dir, root) = folder("allowed"); let (src, dest) = (root.join("src"), root.join("dest")); std::fs::create_dir_all(&src).unwrap(); std::fs::create_dir_all(&dest).unwrap(); @@ -395,13 +393,12 @@ fn only_font_files_are_copied() { let not_a_font = |n: &str| (src.join(n), "not a font file".to_string()); assert_eq!(r.skipped, [not_a_font("notes.txt"), not_a_font("Script")]); assert!(!dest.join("notes.txt").exists() && !dest.join("Script").exists()); - let _ = std::fs::remove_dir_all(&root); } /// A search reads no font file larger than Add Fonts copies, and finds a smaller copy of the font. #[test] fn a_search_skips_font_files_too_large_to_add() { - let root = folder("too-large"); + let (_dir, root) = folder("too-large"); let (big, small) = (root.join("big").join("Sizeme.otf"), root.join("small").join("Sizeme.otf")); let font = vectorcraft_testkit::fonts::renamed("Sizeme Sans 3"); for path in [&big, &small] { @@ -415,7 +412,6 @@ fn a_search_skips_font_files_too_large_to_add() { s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Sizeme Sans 3"}, {"family": "No Such Sans"}]})).unwrap(); let r = finished(&mut s); assert_eq!(r["fonts"][0]["files"], json!([small]), "{r}"); - let _ = std::fs::remove_dir_all(&root); } /// A suitcase font: a file without an extension or with `.suit`, empty but for the fonts in its @@ -424,7 +420,7 @@ fn a_search_skips_font_files_too_large_to_add() { #[test] fn suitcase_fonts_are_found_and_copied_with_their_fork() { use vectorcraft_testkit::fonts::{renamed, suitcase_fork}; - let root = folder("suitcase"); + let (_dir, root) = folder("suitcase"); let (src, dest) = (root.join("src"), root.join("dest")); std::fs::create_dir_all(src.join("sub")).unwrap(); std::fs::create_dir_all(&dest).unwrap(); @@ -467,5 +463,4 @@ fn suitcase_fonts_are_found_and_copied_with_their_fork() { // The font scan reads the copies. let db = vectorcraft_text::FontDb::with_font_dirs(vec![dest.clone()]); assert_eq!((db.styles("Suitme Sans 3"), db.styles("Other Sans 33")), (vec!["Regular".to_string()], vec!["Regular".to_string()])); - let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index a2aea6341d..b8c8bcd325 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -511,7 +511,7 @@ fn an_outlined_stroke_leaves_dither_behind() { /// The Gradient panel's Method (Illustrator 30): `method` set and cleared through the gradient /// commands, on fills and strokes, kept by the params, swatches and a native save and load; -/// anything but classic or perceptual is refused without editing. +/// anything but the four method names is refused without editing. #[test] fn method_is_a_gradient_option_that_saves_and_loads() { use vectorcraft_color::GradientMethod; @@ -548,7 +548,7 @@ fn method_is_a_gradient_option_that_saves_and_loads() { let before = node(&s, id).appearance.clone(); for bad in [json!("smooth"), json!(1), json!(true)] { let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string(); - assert!(e.contains("`method` must be classic or perceptual"), "{e}"); + assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{e}"); assert!(s.execute("paint.setFill", &json!({"gradient": {"method": bad}})).is_err()); } assert_eq!(node(&s, id).appearance, before); @@ -566,6 +566,76 @@ fn method_is_a_gradient_option_that_saves_and_loads() { assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual); } +/// The hue methods: `method` hue-clockwise and hue-counterclockwise set through the gradient +/// commands on fills and strokes, as one undo step, kept by the params, a swatch, a saved preset +/// and a native save and load, and written with or without the hyphen. +#[test] +fn the_hue_methods_are_set_saved_and_loaded() { + use vectorcraft_color::GradientMethod; + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap(); + for (name, method, middle) in + [("hue-clockwise", GradientMethod::HueClockwise, "#ff00ff"), ("hue-counterclockwise", GradientMethod::HueCounterclockwise, "#00ff00")] + { + let undo = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"method": name})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!(g.gradient.method, method); + assert_eq!(g.gradient.stops.len(), 2, "{name}: the stops stay as they were"); + assert_eq!(g.gradient.sample(0.5).0.to_hex(), middle, "{name}: halfway around the wheel"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{name}: one undo step"); + // The params carry it, so a swatch, a preset and a copied paint keep it. + let p = vectorcraft_tools::params::gradient_params(&g); + assert_eq!(p["method"], json!(name)); + let mut q = gradient_param(&g); + q["name"] = json!(format!("Wheel {name}")); + s.execute("swatch.new", &q).unwrap(); + let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch(&format!("Wheel {name}")).map(|w| w.paint.clone()) else { + panic!("a gradient swatch") + }; + assert_eq!(sw.gradient.method, method, "{name}: the swatch"); + s.execute("gradient.presets.save", &json!({"name": format!("Preset {name}")})).unwrap(); + s.execute("paint.editGradient", &json!({"method": "classic"})).unwrap(); + s.execute("gradient.presets.apply", &json!({"name": format!("Preset {name}")})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.method, method, "{name}: the preset"); + // Saved and opened again. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") }; + assert_eq!(saved.gradient.method, method, "{name}: the native file keeps the method"); + } + // Strokes take it too, and the name reads without the hyphen and in any case. + for (name, method) in [("hue-clockwise", GradientMethod::HueClockwise), ("hue-counterclockwise", GradientMethod::HueCounterclockwise)] { + for written in [name.replace('-', " "), name.replace('-', "").to_uppercase()] { + s.execute("paint.setStroke", &json!({"gradient": {"method": "classic"}})).unwrap(); + s.execute("paint.editGradient", &json!({"method": written, "stroke": true})).unwrap(); + let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") }; + assert_eq!(st.gradient.method, method, "{written}"); + } + } + // Reverse gives the same ramp the other way round: the hue method turns with the stops, so + // the middle keeps its color, unless the edit names a method too. + s.execute("paint.editGradient", &json!({"method": "hue-clockwise", "stroke": false})).unwrap(); + let before = fill_gradient(&s, id).gradient; + s.execute("paint.editGradient", &json!({"reverse": true, "stroke": false})).unwrap(); + let after = fill_gradient(&s, id).gradient; + assert_eq!(after.method, GradientMethod::HueCounterclockwise); + assert_eq!((after.stops[0].color.to_hex(), after.stops[1].color.to_hex()), ("#0000ff".to_string(), "#ff0000".to_string())); + for t in [0.25, 0.5, 0.75] { + assert_eq!(after.sample(t).0.to_hex(), before.sample(1.0 - t).0.to_hex(), "reversed at {t}"); + } + s.execute("paint.editGradient", &json!({"reverse": true, "method": "hue-counterclockwise", "stroke": false})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::HueCounterclockwise, "a method in the edit wins"); + // Half a name is refused, nothing changed. + let before = node(&s, id).appearance.clone(); + for bad in ["hue", "clockwise", "hue-clock"] { + let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string(); + assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{bad}: {e}"); + } + assert_eq!(node(&s, id).appearance, before); +} + /// A conic gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params /// agents and swatches read, and the native format. #[test] diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 5eb66ec6c8..159f0604e5 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -275,7 +275,8 @@ fn a_batch_fit_selection_replays_but_an_action_uses_the_current_selection() { #[test] fn a_save_records_its_date_and_replays_it() { - let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned(); + let dir = vectorcraft_testkit::TempDir::new("journal-save"); + let path = dir.join("a.vectorcraft").to_string_lossy().into_owned(); let mut s = Session::new(); s.execute("file.new", &json!({"created": null})).unwrap(); s.execute("document.save", &json!({"path": path})).unwrap(); @@ -290,7 +291,6 @@ fn a_save_records_its_date_and_replays_it() { assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_000)); assert!(s.execute("document.save", &json!({"path": path, "modified": "today"})).is_err()); // Save As stamps the same way; Save a Copy leaves the document's dates (and records none). - let dir = vectorcraft_testkit::temp_dir("journal-save"); s.execute("file.saveAs", &json!({"path": dir.join("b.vectorcraft").to_string_lossy(), "modified": 2_000_000_100})).unwrap(); assert_eq!(s.journal.last().unwrap().1["modified"], json!(2_000_000_100)); s.execute("file.saveCopy", &json!({"path": dir.join("c.vectorcraft").to_string_lossy()})).unwrap(); @@ -316,7 +316,8 @@ fn replayed(s: &Session) -> Session { #[test] fn a_save_in_a_batch_records_its_date_and_replays_it() { - let path = vectorcraft_testkit::temp_dir("journal-batch").join("a.vectorcraft").to_string_lossy().into_owned(); + let dir = vectorcraft_testkit::TempDir::new("journal-batch"); + let path = dir.join("a.vectorcraft").to_string_lossy().into_owned(); let mut s = Session::new(); s.execute("file.new", &json!({"created": 1_000_000_000})).unwrap(); let rect = json!({"x": 10, "y": 10, "width": 50, "height": 40}); @@ -698,7 +699,8 @@ fn a_failed_batch_closes_the_documents_it_opened_and_restores_the_ones_it_closed #[test] fn a_failed_batch_brings_back_a_document_it_reverted() { - let path = vectorcraft_testkit::temp_dir("journal-batch").join("revert.vectorcraft").to_string_lossy().into_owned(); + let dir = vectorcraft_testkit::TempDir::new("journal-batch"); + let path = dir.join("revert.vectorcraft").to_string_lossy().into_owned(); let mut s = Session::new(); s.execute("file.new", &json!({"created": null})).unwrap(); s.execute("document.save", &json!({"path": path, "modified": null})).unwrap(); diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs index d7927ec6bf..c2dccc7bb5 100644 --- a/crates/engine/src/tests/library.rs +++ b/crates/engine/src/tests/library.rs @@ -816,7 +816,7 @@ fn style_order_of(s: &mut Session) -> Vec { #[cfg(not(target_arch = "wasm32"))] #[test] fn a_library_item_rename_is_saved_or_taken_back() { - let dir = vectorcraft_testkit::temp_dir("library-rename-item"); + let dir = vectorcraft_testkit::TempDir::new("library-rename-item"); let folder = dir.to_string_lossy().to_string(); let mut s = session(); s.libraries.set_dir(Some(folder.clone())); @@ -836,7 +836,6 @@ fn a_library_item_rename_is_saved_or_taken_back() { run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Text", "name": " Text "})); assert!(s.execute("library.renameItem", &json!({"kind": "charStyle", "item": "Text", "name": "Copy"})).is_err()); assert_eq!(style_order_of(&mut s), ["Text"], "taken back"); - let _ = std::fs::remove_dir_all(&dir); } #[cfg(not(target_arch = "wasm32"))] @@ -1056,7 +1055,8 @@ fn a_new_item_is_added_where_it_is_dropped() { #[cfg(not(target_arch = "wasm32"))] #[test] fn an_add_that_cannot_be_placed_makes_no_library() { - let folder = vectorcraft_testkit::temp_dir("library-add-unplaced").to_string_lossy().to_string(); + let dir = vectorcraft_testkit::TempDir::new("library-add-unplaced"); + let folder = dir.to_string_lossy().to_string(); let mut s = session(); s.libraries.set_dir(Some(folder.clone())); for bad in [json!({"kind": "fillColor", "color": "#808080", "group": "Nope"}), json!({"kind": "fillColor", "color": "#808080", "after": "Nope"})] @@ -1163,7 +1163,7 @@ fn a_library_of_one_repeated_name_imports_numbered() { #[cfg(not(target_arch = "wasm32"))] #[test] fn a_library_is_exported_to_a_file_and_imported_from_it() { - let dir = vectorcraft_testkit::temp_dir("library-export"); + let dir = vectorcraft_testkit::TempDir::new("library-export"); let path = dir.join("Brand.vclibrary").to_string_lossy().to_string(); let (mut s, g, ..) = brand(); run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}]})); diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 7f70d24ff0..9ed3db2724 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -416,6 +416,34 @@ fn spot_gradients_drawn_as_images_warn() { assert!(!spot_warning(&warnings), "{warnings:?}"); } +/// A spot gradient mixed around the hue wheel is an image in process colors with the warning, +/// where the same stops mixed by Perceptual are a Separation or DeviceN shading: the colors +/// between its stops are not tints or mixes of the stops' inks. +#[test] +fn hue_wheel_spot_gradients_export_as_images_and_warn() { + let (mut s, _) = setup(); + run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true})); + let spot_warning = |w: &[String]| w.iter().any(|w| w.contains("spot colors in gradients exported as images")); + let ink_to_leaf = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Leaf"}]}); + for method in ["hue-clockwise", "hue-counterclockwise"] { + for (what, stops) in [("Ink → paper", ink_to_white(80.0)), ("Ink → Leaf", ink_to_leaf.clone())] { + for kind in ["linear", "radial", "conic", "diamond"] { + run(&mut s, "paint.editGradient", with(stops.clone(), json!({"method": method, "kind": kind}))); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/Subtype/Image"), "{method} {kind} {what}: an image"); + assert!(!pdf.contains("/Separation") && !pdf.contains("/DeviceN"), "{method} {kind} {what}: no spot ink shading"); + assert!(spot_warning(&warnings), "{method} {kind} {what}: {warnings:?}"); + } + } + // Without spot inks: an image, without the warning. + let red_blue = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "kind": "linear"}); + run(&mut s, "paint.editGradient", with(red_blue, json!({"method": method}))); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/Subtype/Image"), "{method}: a process gradient is an image"); + assert!(!spot_warning(&warnings), "{method}: {warnings:?}"); + } +} + #[test] fn freeform_gradients_without_points_warn_about_spot_stops() { let (mut s, id) = setup(); diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs index ef9127042a..6f64a2fe33 100644 --- a/crates/engine/src/tests/linkfiles.rs +++ b/crates/engine/src/tests/linkfiles.rs @@ -14,12 +14,12 @@ use crate::cmd::linkfiles::{self, LinkSearch, Sought}; use crate::tests_links::{BLUE, RED, place, png}; use crate::{EngineError, Session}; -/// A fresh temporary folder (canonical, as a search reports it). -fn folder(tag: &str) -> PathBuf { - let dir = vectorcraft_testkit::temp_dir(&format!("linkfiles-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - findfiles::plain(std::fs::canonicalize(&dir).unwrap()) +/// A fresh temporary folder, removed when the first part is dropped, and its path (canonical, as a +/// search reports it). +fn folder(tag: &str) -> (vectorcraft_testkit::TempDir, PathBuf) { + let dir = vectorcraft_testkit::TempDir::new(&format!("linkfiles-{tag}")); + let path = findfiles::plain(std::fs::canonicalize(&*dir).unwrap()); + (dir, path) } /// Write `bytes` to the file at `path`, making its folders. @@ -80,7 +80,7 @@ fn found(r: &Value, name: &str, root: &Path) -> Vec<(String, String, bool)> { /// innermost out up to the first that differs. #[test] fn files_found_are_ranked_by_what_the_link_saved() { - let root = folder("rank"); + let (_dir, root) = folder("rank"); let mut s = session_for(&root); let (logo, photo) = (png(60, 40, RED), png(50, 30, BLUE)); write(&root.join("orig/logo.png"), &logo); @@ -127,7 +127,7 @@ fn files_found_are_ranked_by_what_the_link_saved() { /// the files found are ranked by the folder names they share with that path. #[test] fn a_classic_mac_path_is_found_by_its_name_and_folders() { - let root = folder("classic"); + let (_dir, root) = folder("classic"); let mut s = session_for(&root); let photo = png(50, 30, BLUE); write(&root.join("orig/photo.png"), &photo); @@ -158,7 +158,7 @@ fn a_classic_mac_path_is_found_by_its_name_and_folders() { #[test] fn parent_components_in_a_saved_path_are_resolved_before_ranking() { for (tag, saved) in [("classic-up", "Old Disk:Projects:Art::photo.png"), ("slash-up", "/old/Projects/Art/../photo.png")] { - let root = folder(tag); + let (_dir, root) = folder(tag); let mut s = session_for(&root); let photo = png(50, 30, BLUE); write(&root.join("orig/photo.png"), &photo); @@ -188,7 +188,7 @@ fn parent_components_in_a_saved_path_are_resolved_before_ranking() { /// files, whatever their status. #[test] fn a_search_looks_for_the_missing_files_or_the_objects_given() { - let root = folder("which"); + let (_dir, root) = folder("which"); let mut s = session_for(&root); write(&root.join("orig/gone.png"), &png(40, 30, RED)); write(&root.join("orig/here.png"), &png(40, 30, BLUE)); @@ -225,7 +225,7 @@ fn a_search_looks_for_the_missing_files_or_the_objects_given() { /// other contents leaves it to search every folder. #[test] fn a_search_ends_once_every_linked_file_has_its_same_file() { - let root = folder("early"); + let (_dir, root) = folder("early"); let mut s = session_for(&root); s.search_threads = Some(1); let bytes = png(40, 30, RED); @@ -265,7 +265,7 @@ impl Visitor for Blocked { #[test] fn stop_ends_only_the_search_it_names() { - let root = folder("stop"); + let (_dir, root) = folder("stop"); std::fs::write(root.join("a.png"), b"png").unwrap(); let mut s = session_for(&root); let (tx, rx) = mpsc::channel(); @@ -290,7 +290,7 @@ fn stop_ends_only_the_search_it_names() { /// roots, the replies are as before. #[test] fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { - let root = folder("read-roots"); + let (_dir, root) = folder("read-roots"); let mut s = session_for(&root); let photo = png(40, 30, RED); write(&root.join("lrf-away/photo.png"), &photo); @@ -343,14 +343,13 @@ fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { let r = ask(&mut s, &narrow, json!({})); assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}"); hidden(&r); - let _ = std::fs::remove_dir_all(&root); } /// A link's saved path relative to the document is shown to a confined caller when it leads to a /// file inside its read roots, and left out when `..` leads out of them. #[test] fn a_relative_link_path_is_judged_from_the_documents_folder() { - let root = folder("read-roots-relative"); + let (_dir, root) = folder("read-roots-relative"); let mut s = session_for(&root); let home = root.join("home"); write(&root.join("lrf-away/photo.png"), &png(40, 30, RED)); @@ -370,14 +369,13 @@ fn a_relative_link_path_is_judged_from_the_documents_folder() { assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}"); finished(&mut s); } - let _ = std::fs::remove_dir_all(&root); } /// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link` /// null), as `links.info` leaves it out, and keeps the other fields. #[test] fn image_info_leaves_out_a_link_outside_the_read_roots() { - let root = folder("read-roots-info"); + let (_dir, root) = folder("read-roots-info"); let mut s = session_for(&root); let pic = root.join("lrf-away/photo.png"); write(&pic, &png(40, 30, RED)); @@ -391,12 +389,11 @@ fn image_info_leaves_out_a_link_outside_the_read_roots() { let info = ask(&mut s, "links.info"); assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}"); assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic)); - let _ = std::fs::remove_dir_all(&root); } #[test] fn relinking_with_copy_puts_the_file_in_the_documents_folder() { - let root = folder("copy"); + let (_dir, root) = folder("copy"); let mut s = session_for(&root); write(&root.join("orig/photo.png"), &png(40, 30, RED)); let id = placed(&mut s, &root.join("orig/photo.png")); @@ -443,14 +440,13 @@ fn relinking_with_copy_puts_the_file_in_the_documents_folder() { let image = placed(&mut unsaved, &found); let r = unsaved.execute("links.relink", &json!({"ids": [image], "path": found, "copy": true})); assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("save the document first")), "{r:?}"); - let _ = std::fs::remove_dir_all(&root); } /// Copy takes images only: with copy, a call that includes a placed document fails and copies /// nothing; without it, the document is relinked where its file is. #[test] fn copy_refuses_placed_documents() { - let root = folder("copy-placed"); + let (_dir, root) = folder("copy-placed"); let mut s = session_for(&root); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); let badge = root.join("badge.vectorcraft"); @@ -471,7 +467,7 @@ fn copy_refuses_placed_documents() { #[cfg(unix)] #[test] fn a_copy_stays_inside_the_write_root() { - let root = folder("roots"); + let (_dir, root) = folder("roots"); let mut s = session_for(&root); write(&root.join("orig/photo.png"), &png(40, 30, RED)); let id = placed(&mut s, &root.join("orig/photo.png")); @@ -487,14 +483,13 @@ fn a_copy_stays_inside_the_write_root() { assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("outside the write root")), "{r:?}"); assert!(!root.join("doc/photo 2.png").exists()); assert_eq!(std::fs::read(allowed.join("photo.png")).unwrap(), b"kept as it is"); - let _ = std::fs::remove_dir_all(&root); } /// An SVG's linked files are read on the importer's own thread (it needs a deep stack): the roots /// in force on the thread that opens the SVG hold there too. #[test] fn an_svg_reads_its_links_within_the_read_root() { - let root = folder("svg-roots"); + let (_dir, root) = folder("svg-roots"); for dir in ["in", "out"] { write(&root.join(dir).join("dot.png"), &png(4, 4, RED)); } @@ -513,12 +508,11 @@ fn an_svg_reads_its_links_within_the_read_root() { assert_eq!(import("in"), inside, "inside the read root: read as before"); assert_ne!(import("out"), outside, "outside the read root: not read"); }); - let _ = std::fs::remove_dir_all(&root); } #[test] fn junk_search_params_never_fail_internally() { - let root = folder("junk"); + let (_dir, root) = folder("junk"); let mut s = session_for(&root); write(&root.join("orig/a.png"), &png(4, 4, RED)); let id = placed(&mut s, &root.join("orig/a.png")); diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index 9787401285..e683b156e1 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind}; +use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind, StoredRoot}; use vectorcraft_geom::Rect; use crate::*; @@ -446,8 +446,8 @@ fn links_outside_the_layers_find_their_files_after_the_folder_moves() { } /// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved -/// with the document is read again for the layers' image and for the symbol's, and both links take -/// its new path, size and hash. +/// with the document is read again for the layers' image and for the symbol's, both are listed by +/// their ids, and both links take its new path, size and hash. #[test] fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { let a = Folder::new("nested-modified-a"); @@ -458,13 +458,14 @@ fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); let id = place(&mut s, &pic); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &pic); save(&mut s, &a.file("poster.vectorcraft")); write(&pic, &png(30, 30, BLUE)); std::fs::remove_dir_all(&b.0).unwrap(); std::fs::rename(&a.0, &b.0).unwrap(); let r = open(&mut s, &b.file("poster.vectorcraft")); - assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}"); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0, in_symbol.0])), "{r}"); let moved = PathBuf::from(b.file("art/photo.png")); let blue = png(30, 30, BLUE); let mut images = vec![]; @@ -521,7 +522,8 @@ fn a_symbols_link_without_a_relative_path_follows_the_moved_file() { /// Update Links reads a changed file again for the images inside symbols, patterns and opacity /// masks too: those showing the same content as an image in the layers, and those no object in the -/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step. +/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step, +/// and the reply lists each by its id. #[test] fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let dir = Folder::new("nested-update"); @@ -533,11 +535,13 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let id = place(&mut s, &photo); s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &photo); // logo.png only in a pattern and in an opacity mask. let p = place(&mut s, &logo); s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap(); s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap(); + let p = stored_image_in(&s, StoredRoot::Pattern { pattern: 0, art: 0 }); let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); let mask = place(&mut s, &logo); s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); @@ -546,7 +550,8 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { write(&photo, &blue); write(&logo, &green); let u = s.execute("links.update", &json!({})).unwrap(); - assert_eq!(u, json!({"updated": [id.0], "missing": []})); + // By file: photo.png in the layers and the symbol, then logo.png in the mask and the pattern. + assert_eq!(u, json!({"updated": [id.0, in_symbol.0, mask.0, p.0], "missing": []})); // (name, size, hash, pixel size) of each image, which shows its file's pixels. let images = |s: &Session| { let d = &s.doc().unwrap().doc; @@ -576,6 +581,193 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s)); } +/// The files only art outside the layers shows: one in a symbol, a graph design, a pattern and an +/// opacity mask each, no object in the layers linked to any of them. +const NESTED: [&str; 4] = ["symbol.png", "design.png", "pattern.png", "mask.png"]; + +/// A document whose only links are [`NESTED`]'s files in `dir`. +fn nested_links(dir: &Folder) -> Session { + for (i, name) in (0u8..).zip(NESTED) { + write(&dir.file(name), &png(20, 10, [20 + 50 * i, 90, 160])); + } + let mut s = session(); + let id = place(&mut s, &dir.file("symbol.png")); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [s.doc().unwrap().selection.objects[0].0]})).unwrap(); + let id = place(&mut s, &dir.file("design.png")); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let id = place(&mut s, &dir.file("pattern.png")); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &dir.file("mask.png")); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s +} + +/// The id of the `links.list` row of file `name`. +fn row_id(s: &mut Session, name: &str) -> u64 { + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == name).unwrap_or_else(|| panic!("{name} in {list}")); + row["id"].as_u64().unwrap() +} + +/// The link of stored object `id`. +fn stored_link(s: &Session, id: u64) -> Option { + match &s.doc().unwrap().doc.stored_node(NodeId(id)).unwrap().0.kind { + NodeKind::Image(im) => im.link.clone(), + k => panic!("not an image: {k:?}"), + } +} + +/// The id of the first image stored in the art of `root`. +fn stored_image_in(s: &Session, root: StoredRoot) -> NodeId { + let mut found = None; + s.doc().unwrap().doc.visit_stored(|n, place| { + if found.is_none() && place.root == root && matches!(n.kind, NodeKind::Image(_)) { + found = Some(n.id); + } + }); + found.unwrap_or_else(|| panic!("no image in {root:?}")) +} + +/// Images in symbols, graph designs, patterns and opacity masks are listed and checked with ids of +/// their own, one row each with what holds it, and the missing filter counts them. +#[test] +fn links_outside_the_layers_are_listed_and_checked_with_ids() { + let dir = Folder::new("nested-ids"); + let mut s = nested_links(&dir); + let list = s.execute("links.list", &json!({})).unwrap(); + let rows = list["links"].as_array().unwrap(); + let owners: Vec<(&str, &str)> = rows.iter().map(|r| (r["name"].as_str().unwrap(), r["owner"]["kind"].as_str().unwrap_or("-"))).collect(); + assert_eq!(owners, [("mask.png", "layers"), ("symbol.png", "symbol"), ("design.png", "graphDesign"), ("pattern.png", "pattern")], "{list}"); + assert_eq!(rows[1]["owner"]["name"], "Mark"); + let masked = s.doc().unwrap().doc.layers[0].children().unwrap().iter().find(|n| n.extras().mask.is_some()).unwrap().id; + assert_eq!(rows[0]["owner"]["maskOf"], json!(masked.0)); + let ids: std::collections::BTreeSet = rows.iter().map(|r| r["id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 4, "{list}"); + assert!(rows.iter().all(|r| r["status"] == "ok" && r["linked"] == true), "{list}"); + let check = s.execute("links.check", &json!({})).unwrap(); + let checked: Vec<(&str, &Value)> = check["links"].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap(), &l["ids"])).collect(); + assert_eq!(checked.len(), 4, "{check}"); + for (name, ids) in checked { + assert_eq!(*ids, json!([row_id(&mut s, name)]), "{check}"); + } + std::fs::remove_file(dir.file("design.png")).unwrap(); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((check["missing"].as_u64(), check["modified"].as_u64()), (Some(1), Some(0)), "{check}"); + let design = row_id(&mut s, "design.png"); + let missing = s.execute("links.list", &json!({"show": "missing"})).unwrap(); + assert_eq!((missing["missing"].as_u64(), &missing["links"][0]["id"]), (Some(1), &json!(design)), "{missing}"); + let one = s.execute("links.check", &json!({"ids": [design]})).unwrap(); + assert_eq!(one["links"][0]["status"], "missing", "{one}"); +} + +/// Every links command takes the id of an image outside the layers and acts on that object: Link +/// Info, Go To (which clears the selection and names the symbol), Placement Options, Relink, Update and +/// Embed. +#[test] +fn links_commands_act_on_images_outside_the_layers_by_id() { + let dir = Folder::new("nested-act"); + let mut s = nested_links(&dir); + let symbol = row_id(&mut s, "symbol.png"); + let info = s.execute("links.info", &json!({"id": symbol})).unwrap(); + assert_eq!((info["fileName"].as_str(), info["status"].as_str(), info["id"].as_u64()), (Some("symbol.png"), Some("ok"), Some(symbol)), "{info}"); + let other = super::rect(&mut s, 0.0, 0.0, 5.0, 5.0); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let go = s.execute("links.goTo", &json!({"id": symbol})).unwrap(); + assert_eq!( + (go["onCanvas"].clone(), go["owner"]["kind"].clone(), go["owner"]["name"].clone()), + (json!(false), json!("symbol"), json!("Mark")), + "{go}" + ); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing on the canvas is selected"); + s.execute("links.placementOptions", &json!({"ids": [symbol], "preserve": "fit"})).unwrap(); + let placement = s.execute("links.placementOptions", &json!({"ids": [symbol]})).unwrap(); + assert_eq!(placement["placement"]["preserve"], "fit"); + // Relink: the symbol's image shows the new file. + let other = dir.file("other.png"); + write(&other, &png(40, 20, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": other})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + assert_eq!(stored_link(&s, symbol).unwrap().path, other); + // Update: a changed pattern file is read again for the pattern's image. + let pattern = row_id(&mut s, "pattern.png"); + let changed = png(30, 30, RED); + write(&dir.file("pattern.png"), &changed); + let r = s.execute("links.update", &json!({"ids": [pattern]})).unwrap(); + assert_eq!(r["updated"], json!([pattern]), "{r}"); + assert_eq!(stored_link(&s, pattern).unwrap().size, Some(changed.len() as u64)); + // Embed: the design's image keeps its pixels and no longer links. + let design = row_id(&mut s, "design.png"); + let r = s.execute("links.embed", &json!({"ids": [design]})).unwrap(); + assert_eq!(r["embedded"], json!([design]), "{r}"); + assert!(stored_link(&s, design).is_none()); + let row = s.execute("links.list", &json!({"show": "embedded"})).unwrap(); + assert_eq!(row["links"][0]["id"], json!(design), "{row}"); + // Relink All Instances takes the other stored objects linked to the same file. + let mask = row_id(&mut s, "mask.png"); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": dir.file("mask.png"), "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + let r = s.execute("links.relink", &json!({"ids": [mask], "path": other, "scope": "all"})).unwrap(); + let mut relinked: Vec = r["relinked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + relinked.sort(); + let mut both = vec![symbol, mask]; + both.sort(); + assert_eq!(relinked, both, "{r}"); +} + +/// Opening a document reports missing and modified files that only art outside the layers shows, +/// by their objects' ids, and those ids stay the same after the document is saved and opened again. +#[test] +fn open_reports_links_outside_the_layers_by_id() { + let dir = Folder::new("nested-open"); + let mut s = nested_links(&dir); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + let ids: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + std::fs::remove_file(dir.file("symbol.png")).unwrap(); + write(&dir.file("pattern.png"), &png(30, 30, RED)); + let r = open(&mut s, &path); + let names = |k: &str| r[k].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap().to_string(), l["ids"].clone())).collect::>(); + assert_eq!(names("missingLinks"), [("symbol.png".to_string(), json!([ids[0]]))], "{r}"); + assert_eq!(names("modifiedLinks"), [("pattern.png".to_string(), json!([ids[2]]))], "{r}"); + let again: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + assert_eq!(again, ids, "the ids are saved"); +} + +/// A document whose symbol and mask art repeat the ids of objects in the layers (or have none) +/// opens with ids of their own there, the layer tree's kept; the links commands then tell them apart. +#[test] +fn nested_objects_that_repeat_ids_open_with_ids_of_their_own() { + let dir = Folder::new("nested-repeat"); + let mut s = nested_links(&dir); + let layer_image = place(&mut s, &dir.file("symbol.png")); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + // The symbol's image takes the id of the image in the layers, the mask's image none. + let mut saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + saved["document"]["symbols"][0]["art"]["id"] = json!(layer_image.0); + let layer = &mut saved["document"]["layers"][0]["kind"]["children"]; + let masked = layer.as_array_mut().unwrap().iter_mut().find(|n| n.get("mask").is_some() || n["extras"].get("mask").is_some()).unwrap(); + let mask = if masked.get("mask").is_some() { &mut masked["mask"] } else { &mut masked["extras"]["mask"] }; + mask["art"]["id"] = json!(0); + std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap(); + open(&mut s, &path); + let list = s.execute("links.list", &json!({})).unwrap(); + let ids: Vec = list["links"].as_array().unwrap().iter().map(|r| r["id"].as_u64().unwrap()).collect(); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!((ids.len(), unique.len()), (5, 5), "{list}"); + assert!(!ids.contains(&0), "{list}"); + assert_eq!(list["links"][0]["id"], json!(layer_image.0), "the layer tree keeps its ids: {list}"); + let symbol = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").unwrap()["id"].as_u64().unwrap(); + s.execute("links.embed", &json!({"ids": [symbol]})).unwrap(); + assert!(stored_link(&s, symbol).is_none()); + assert!(image(&s, layer_image).link.is_some(), "the image in the layers stays linked"); +} + /// An image that Placement Options clip, alone in a group the user made, goes into a new clip group /// inside that group when its file grows, in the layers and in a symbol's art; the user's group /// stays a plain group. diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index f029eccd3d..a9a3a17ec7 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -213,7 +213,7 @@ fn go_to_selects_the_image() { run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); assert_ne!(s.doc().unwrap().selection.objects, vec![id]); let r = run(&mut s, "links.goTo", json!({"id": id.0})); - assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0]})); + assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0], "onCanvas": true})); assert_eq!(s.doc().unwrap().selection.objects, vec![id]); let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[1].id; assert!(s.execute("links.goTo", &json!({"id": rect.0})).is_err(), "not an image"); diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index 7fc8f0a4ba..0a5b9d7dbe 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -47,6 +47,30 @@ fn update_changed_reads_changed_files_again_once() { assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1); } +/// A file only a symbol's art shows is watched too, and read again by the id of the symbol's image. +#[test] +fn update_changed_reads_a_file_only_a_symbol_shows() { + let dir = Folder::new("watch-symbol"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + let id = place(&mut s, &path); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0]; + s.execute("edit.clear", &json!({"ids": [instance.0]})).unwrap(); + let mut in_symbol = None; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) { + in_symbol = Some(n.id.0); + } + }); + let in_symbol = in_symbol.expect("the symbol's image"); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([])); + save(&path, &png(40, 40, BLUE)); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([in_symbol])); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0); +} + #[test] fn a_background_look_follows_update_links() { for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] { diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 44a0ddde1b..abb0bf31eb 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -4040,9 +4040,11 @@ fn ruler_guides_select_move_and_delete() { assert!(selected(&s).is_empty()); assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1])); let layer = s.doc().unwrap().current_layer().unwrap().0; + let id = s.doc().unwrap().doc.ruler_guides()[1].id.unwrap().0; assert_eq!( s.execute("guide.list", &json!({})).unwrap()[1], - json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + json!({"index": 1, "id": id, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}) ); assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err()); // A move takes each selected guide along its own axis, in one undo step. @@ -4323,11 +4325,14 @@ fn artboard_guides_go_with_their_artboard() { s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap(); let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap(); let layer = s.doc().unwrap().current_layer().unwrap().0; + let ids: Vec = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect(); assert_eq!( list(&mut s), json!([ - {"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}, - {"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true}, + {"index": 0, "id": ids[0], "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}, + {"index": 1, "id": ids[1], "vertical": false, "pos": 100.0, "selected": false, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}, ]) ); let spans = |s: &Session| { @@ -4368,7 +4373,8 @@ fn artboard_guides_go_with_their_artboard() { assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1])); assert_eq!( list(&mut s)[0], - json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true}) + json!({"index": 0, "id": ids[1], "vertical": false, "pos": 100.0, "selected": true, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}) ); assert_eq!(undo_len(&s), n + 1); s.execute("edit.undo", &json!({})).unwrap(); @@ -4412,9 +4418,11 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() { s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap(); s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap(); let layer = s.doc().unwrap().current_layer().unwrap().0; + let id = s.doc().unwrap().doc.ruler_guides()[1].id.unwrap().0; assert_eq!( s.execute("guide.list", &json!({})).unwrap()[1], - json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true}) + json!({"index": 1, "id": id, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, + "hidden": false, "locked": false, "shown": true, "editable": true}) ); } @@ -4569,6 +4577,12 @@ fn ruler_guides_live_on_layers() { assert!(s.doc().unwrap().doc.ruler_guides().iter().all(|g| g.layer == Some(first))); s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); + // `indexes` that is not a list, or given with `index`, is an error; an index given twice counts once. + assert!(s.execute("guide.setLayer", &json!({"indexes": 0, "layer": first.0})).is_err()); + assert!(s.execute("guide.setLayer", &json!({"index": 0, "indexes": [1], "layer": first.0})).is_err()); + assert!(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.layer).eq([Some(first), Some(second)])); + assert_eq!(s.execute("guide.setLayer", &json!({"indexes": [1, 1], "layer": first.0})).unwrap()["count"], 1); + s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); // Deleting a layer deletes its guides; undo brings them back. s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap(); assert_eq!(guides(&s), [(true, 200.0)]); @@ -4606,6 +4620,122 @@ fn guides_on_hidden_or_locked_layers_are_left_alone() { assert_eq!(guides(&s).len(), 3); } +/// A guide hidden or locked on its own can't be moved, deleted or moved to another layer, and the +/// error names the guide, not its layer; a guide on a hidden or locked layer names the layer. +#[test] +fn a_guide_hidden_or_locked_on_its_own_is_named_in_the_error() { + let mut s = session(); + let first = s.doc().unwrap().current_layer().unwrap(); + for pos in [100.0, 200.0] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let ids: Vec = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect(); + s.execute("layer.setProps", &json!({"ids": [ids[0]], "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [ids[1]], "visible": false})).unwrap(); + let own = "the guide is hidden or locked"; + for i in [0, 1] { + assert_eq!(s.execute("guide.move", &json!({"index": i, "pos": 50})).unwrap_err().to_string(), own, "{i}"); + assert_eq!(s.execute("guide.remove", &json!({"index": i})).unwrap_err().to_string(), own, "{i}"); + assert_eq!(s.execute("guide.remove", &json!({"indexes": [i]})).unwrap_err().to_string(), own, "{i}"); + let p = json!({"index": i, "layer": second.0}); + assert_eq!(s.execute("guide.setLayer", &p).unwrap_err().to_string(), own, "{i}"); + } + // Shown and unlocked, but on a locked layer: the error names the layer. + s.execute("layer.setProps", &json!({"ids": [ids[0]], "locked": false})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap(); + let e = s.execute("guide.move", &json!({"index": 0, "pos": 50})).unwrap_err().to_string(); + assert_eq!(e, "the guide is on a hidden or locked layer"); +} + +/// Object › Lock › Selection and Hide › Selection lock and hide a selected ruler guide on its own, +/// with the selected art, in one undo step; Unlock All and Show All unlock and show it again and +/// select it as a guide. While Lock Guides is on nothing selects it, and in isolation mode Lock +/// leaves it alone. +#[test] +fn lock_hide_unlock_and_show_all_reach_ruler_guides() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + for pos in [100.0, 200.0] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let flags = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.hidden, g.locked)).collect::>(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let clear = (false, false); + // A guide selected on its own: Lock locks it alone and deselects it. + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + let n = undo(&s); + s.execute("object.lock", &json!({})).unwrap(); + assert_eq!((flags(&s), undo(&s)), (vec![(false, true), clear], n + 1)); + assert!(selected_guides(&s).is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((flags(&s), selected_guides(&s)), (vec![clear, clear], vec![0])); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(flags(&s)[0], (false, true)); + // A guide and art selected together: Hide hides both, in one step. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap(); + let n = undo(&s); + s.execute("object.hide", &json!({})).unwrap(); + assert_eq!((flags(&s), undo(&s)), (vec![(false, true), (true, false)], n + 1)); + assert!(!s.doc().unwrap().doc.node(a).unwrap().visible); + // Unlock All and Show All reach the guides and select them as guides. + assert_eq!(s.execute("object.unlockAll", &json!({})).unwrap()["count"], 1); + assert_eq!((flags(&s)[0], selected_guides(&s), selected(&s)), (clear, vec![0], vec![])); + assert_eq!(s.execute("object.showAll", &json!({})).unwrap()["count"], 2); + assert_eq!((flags(&s), selected_guides(&s), selected(&s)), (vec![clear, clear], vec![1], vec![a])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(flags(&s)[1], (true, false), "Show All is one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + // With Lock Guides on, Unlock All unlocks a guide but leaves it unselected. + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + s.execute("object.unlockAll", &json!({})).unwrap(); + assert_eq!((flags(&s)[0], selected_guides(&s)), (clear, vec![])); + s.execute("view.guides.lock", &json!({"locked": false})).unwrap(); + // In isolation mode Lock takes only the objects inside the isolated group. + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + sel(&mut s, &[a]); + let _ = s.execute("guide.select", &json!({"indexes": [0], "toggle": true})); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(a).unwrap().locked); + assert_eq!(flags(&s)[0], clear); +} + +/// `guide.remove` with `indexes` deletes those guides in one undo step, an index given twice +/// once; a locked guide among them, or an unknown index, deletes nothing. +#[test] +fn guide_remove_takes_several_indexes() { + let mut s = session(); + for pos in [100.0, 200.0, 300.0] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let id = s.doc().unwrap().doc.ruler_guides()[2].id.unwrap(); + s.execute("layer.setProps", &json!({"ids": [id.0], "locked": true})).unwrap(); + assert!(s.execute("guide.remove", &json!({"indexes": [0, 2]})).is_err()); + assert!(s.execute("guide.remove", &json!({"indexes": [0, 7]})).is_err()); + assert!(s.execute("guide.remove", &json!({"indexes": 0})).is_err()); + assert!(s.execute("guide.remove", &json!({"index": 0, "indexes": [1]})).is_err()); + // A list longer than 10,000 entries is refused by every guide command that takes one. + let long = json!(vec![0; 10_001]); + assert!(s.execute("guide.remove", &json!({"indexes": long})).is_err()); + assert!(s.execute("guide.select", &json!({"indexes": long})).is_err()); + let layer = s.doc().unwrap().doc.layers[0].id; + assert!(s.execute("guide.setLayer", &json!({"indexes": long, "layer": layer.0})).is_err()); + assert_eq!(s.execute("guide.select", &json!({"indexes": vec![0; 10_000]})).unwrap()["selected"], json!([0])); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 3); + let n = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("guide.remove", &json!({"indexes": [1, 0, 1]})).unwrap()["count"], 2); + assert_eq!((guides(&s), s.doc().unwrap().history.undo.len()), (vec![(true, 300.0)], n + 1)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 3); +} + /// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo /// step each. #[test] diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index deaf60b64f..209d22a42a 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -631,6 +631,39 @@ fn the_profile_follows_the_preset_access_and_reset() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn profile_errors_name_the_file_by_its_name_outside_the_read_roots() { + let (mut s, dir) = with_profiles("errors"); + let elsewhere = dir.with_extension("elsewhere"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + let whole = dir.join("Print.vectorcraft").to_string_lossy().to_string(); + let run = |s: &mut Session, roots: &std::sync::Arc, cmd: &str| { + crate::file_access::confine(Some(roots), || s.execute(cmd, &json!({"name": "Print"}))).unwrap_err().to_string() + }; + // No write access: edit and reset are refused before anything is written or deleted. + let read_only = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[]).unwrap().unwrap(); + for cmd in ["file.newProfiles.edit", "file.newProfiles.reset"] { + let e = run(&mut s, &read_only, cmd); + assert!(e.ends_with(": Print.vectorcraft") && !e.contains(&whole), "{cmd}: {e}"); + } + // Write access without read access: edit is refused, and nothing is written. + let write_only = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap(); + let e = run(&mut s, &write_only, "file.newProfiles.edit"); + assert!(e.contains("outside the read root") && e.ends_with(": Print.vectorcraft") && !e.contains(&whole), "{e}"); + assert!(!dir.join("Print.vectorcraft").exists()); + // A file reset cannot delete (a folder in its place) is named by its file name too. + std::fs::create_dir_all(dir.join("Print.vectorcraft")).unwrap(); + let e = run(&mut s, &write_only, "file.newProfiles.reset"); + assert!(e.starts_with("Print.vectorcraft: ") && !e.contains(&whole), "{e}"); + // Inside the read roots the error names the whole path. + let inside = crate::file_access::AutomationRoots::new(&[dir.as_path()], &[dir.as_path()]).unwrap().unwrap(); + let e = run(&mut s, &inside, "file.newProfiles.reset"); + assert!(e.starts_with(&format!("{whole}: ")), "{e}"); + let _ = std::fs::remove_dir_all(&elsewhere); + let _ = std::fs::remove_dir_all(&dir); +} + /// The new document's links find their files from the profile's file, as a document opened from /// that file does; a document without a profile has no such file. #[test] diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index 4ae8321167..d9e76f85bf 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -681,3 +681,197 @@ fn the_space_saves_and_reopens_and_goes_with_a_copy() { let copy = s.doc().unwrap().selection.objects.to_vec(); assert_eq!(space(&s, copy[0]), (Some(40.0), 25.0)); } + +/// Where `id` is in the tree: its parent and its index there (0 at the bottom). +fn place_of(s: &Session, id: NodeId) -> (Option, usize) { + let (parent, index, _) = s.doc().unwrap().doc.position(id).unwrap(); + (parent, index) +} + +#[test] +fn detach_takes_the_chosen_object_off_the_path_and_the_others_move_up_in_one_undo_step() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({"rotate": 30})); + let (layer, at) = place_of(&s, g); + s.execute("object.objectsOnPath.selectMember", &json!({"member": middle.0})).unwrap(); + let drawn = s.doc().unwrap().doc.node(middle).unwrap().clone(); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.detach", &json!({})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": middle.0})); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + let (on_path, children) = orders(&s, g); + assert_eq!(on_path, [left, right]); + assert!(children.len() == 3 && !children.contains(&middle), "{children:?}"); + assert_eq!(place_of(&s, middle), (layer, at + 1), "just above the group, in its layer"); + assert_eq!(s.doc().unwrap().doc.node(middle), Some(&drawn), "the object keeps its art, place and rotation"); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[middle][..], None), "the detached object alone is selected"); + // The two left stay on the ends of the path; the path stays. + assert_eq!(center(&s, left), Point::new(50.0, 300.0)); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); + assert_eq!(s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().unwrap().path, path); + // The detached object is free: moving it moves it, and the group does not take it back. + s.execute("object.move", &json!({"ids": [middle.0], "dx": 0, "dy": -100})).unwrap(); + assert_eq!(center(&s, middle), Point::new(200.0, 200.0)); + assert_eq!(orders(&s, g).0, [left, right]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let (on_path, children) = orders(&s, g); + assert_eq!(on_path, [left, middle, right], "undo puts it back on the path"); + assert!(children.contains(&middle), "{children:?}"); + assert_eq!(center(&s, middle), Point::new(200.0, 300.0)); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(middle)), "and the choice back"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [left, right]); + assert_eq!(place_of(&s, middle), (layer, at + 1)); +} + +#[test] +fn after_the_first_object_is_detached_the_second_takes_the_start() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({})); + select(&mut s, &[]); + let v = s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": left.0})); + assert_eq!(orders(&s, g).0, [middle, right]); + assert_eq!(center(&s, middle), Point::new(50.0, 300.0), "the second object moves up to the start"); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); + assert_eq!(center(&s, left), Point::new(50.0, 300.0), "the detached object stays where it was drawn"); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [left]); +} + +#[test] +fn detach_on_a_closed_path_spreads_the_others_all_the_way_round_again() { + let mut s = session(); + let ids: Vec = (0..4).map(|i| rect(&mut s, 10.0 + 40.0 * f64::from(i), 0.0, 10.0, 10.0)).collect(); + let ring = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let g = attach(&mut s, ring, &ids, json!({})); + s.execute("object.objectsOnPath.detach", &json!({"member": ids[1].0})).unwrap(); + assert_eq!(orders(&s, g).0, [ids[0], ids[2], ids[3]]); + let data = s.doc().unwrap().doc.node(ring).unwrap().path_data().unwrap().clone(); + let stops = vectorcraft_doc::objects_on_path::stops(&data, 3, None, 0.0).unwrap(); + for (id, (p, _)) in [ids[0], ids[2], ids[3]].iter().zip(stops) { + let c = center(&s, *id); + assert!((c.x - p.x).abs() < 1e-3 && (c.y - p.y).abs() < 1e-3, "a third of the way round apart: {c:?} {p:?}"); + } +} + +#[test] +fn detach_keeps_a_space_value_and_the_start() { + let mut s = session(); + let (g, [a, b, c], _) = three_on_a_line(&mut s); + s.execute("object.objectsOnPath.options", &json!({"start": 20})).unwrap(); + s.execute("object.objectsOnPath.detach", &json!({"member": a.0})).unwrap(); + assert_eq!(space(&s, g), (Some(100.0), 20.0)); + assert_eq!(center(&s, b), Point::new(70.0, 300.0), "the second object moves up to the first place"); + assert_eq!(center(&s, c), Point::new(170.0, 300.0)); + // A Space value the path is too short for stays, and two objects get more of the path. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.objectsOnPath.options", &json!({"spacing": 200, "start": 0})).unwrap(); + assert_eq!(center(&s, c), Point::new(350.0, 300.0), "three objects drawn 150 pt apart"); + s.execute("object.objectsOnPath.detach", &json!({"member": b.0})).unwrap(); + assert_eq!(space(&s, g), (Some(200.0), 0.0)); + assert_eq!(center(&s, a), Point::new(50.0, 300.0)); + assert_eq!(center(&s, c), Point::new(250.0, 300.0), "two objects 200 pt apart"); +} + +#[test] +fn the_group_and_its_path_stay_with_one_object_or_none_left() { + let mut s = session(); + let ([left, middle, _], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle], json!({"pivot": 7})); + s.execute("object.objectsOnPath.options", &json!({"spacing": 30})).unwrap(); + s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(orders(&s, g), (vec![middle], vec![path, middle])); + let b = bounds(&s, middle); + assert!((b.center().x - 50.0).abs() < 1e-6 && (b.y1 - 300.0).abs() < 1e-6, "the one left on the start, by its Pivot: {b:?}"); + s.execute("object.objectsOnPath.detach", &json!({"member": middle.0})).unwrap(); + let n = s.doc().unwrap().doc.node(g).unwrap().clone(); + let rec = n.extras().objects_on_path.as_deref().unwrap(); + assert_eq!((rec.path, rec.members.len(), rec.pivot, rec.spacing), (path, 0, 7, Some(30.0)), "a live group with no objects"); + assert_eq!(orders(&s, g).1, [path]); + // An object moved back in joins the path. + s.execute("layer.move", &json!({"ids": [left.0], "target": g.0, "place": "inside"})).unwrap(); + assert_eq!(orders(&s, g).0, [left]); +} + +#[test] +fn detach_refuses_what_is_not_one_chosen_editable_object_on_a_path() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.detach", &p).unwrap_err().to_string(); + let choose = "choose the object to detach with its Select/Move widget"; + let steps = s.doc().unwrap().history.undo.len(); + select(&mut s, &[g]); + assert!(err(&mut s, json!({})).contains(choose), "the group without a chosen object"); + select(&mut s, &[]); + assert!(err(&mut s, json!({})).contains(choose), "nothing selected"); + assert!(err(&mut s, json!({"member": path.0})).contains("`member` must name an object attached"), "the path"); + assert!(err(&mut s, json!({"member": g.0})).contains("`member` must name"), "the group itself"); + assert!(err(&mut s, json!({"member": loose.0})).contains("`member` must name"), "an object on no path"); + assert!(err(&mut s, json!({"member": 99999})).contains("no such object")); + assert!(err(&mut s, json!({"member": "a"})).contains("invalid object id")); + // Hidden and locked objects stay; a locked group keeps all of its objects. + select(&mut s, &[c]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": c.0})).contains("`member` names an object that is hidden or locked")); + s.execute("object.unlockAll", &json!({})).unwrap(); + select(&mut s, &[b]); + s.execute("object.hide", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": b.0})).contains("hidden or locked"), "a hidden object"); + s.execute("object.showAll", &json!({})).unwrap(); + select(&mut s, &[g]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": a.0})).contains("the Objects on Path group is hidden or locked")); + s.execute("object.unlockAll", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused detached anything"); + let undone = s.doc().unwrap().history.undo.len(); + assert_eq!(undone, steps + 6, "only the lock, hide and show steps"); +} + +#[test] +fn an_object_detached_from_a_group_on_another_path_does_not_join_that_path() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let inner = attach(&mut s, path, &[left, middle], json!({})); + let outer_line = line(&mut s, (20.0, 380.0), (380.0, 380.0)); + let outer = attach(&mut s, outer_line, &[inner, right], json!({})); + let outer_members = orders(&s, outer).0; + s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(orders(&s, inner).0, [middle]); + assert_eq!(orders(&s, outer).0, outer_members, "the outer path keeps its objects and takes no new one"); + let (layer, at) = place_of(&s, outer); + assert_eq!(place_of(&s, left), (layer, at + 1), "above the outer group"); +} + +#[test] +fn hidden_and_locked_objects_left_on_the_path_move_up_too() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({})); + select(&mut s, &[right]); + s.execute("object.lock", &json!({})).unwrap(); + select(&mut s, &[middle]); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("object.objectsOnPath.detach", &json!({"member": left.0})).unwrap(); + assert_eq!(orders(&s, g).0, [middle, right]); + assert_eq!(center(&s, middle), Point::new(50.0, 300.0), "the hidden object moves up to the start"); + assert_eq!(center(&s, right), Point::new(350.0, 300.0)); +} + +#[test] +fn objects_moved_out_together_in_the_layers_panel_all_leave_the_path() { + let mut s = session(); + let ([left, middle, right], path) = squares_and_line(&mut s); + let g = attach(&mut s, path, &[left, middle, right], json!({})); + s.execute("layer.move", &json!({"ids": [left.0, middle.0], "target": g.0, "place": "above"})).unwrap(); + assert_eq!(orders(&s, g), (vec![right], vec![path, right])); + assert_eq!(center(&s, right), Point::new(50.0, 300.0), "the one left takes the start"); + let (layer, at) = place_of(&s, g); + assert_eq!((place_of(&s, left), place_of(&s, middle)), ((layer, at + 1), (layer, at + 2)), "above the group, in their order"); +} diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 1c931e0de8..b8c4724853 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -629,6 +629,89 @@ fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { } } +/// Selected type "Hi" at 48 pt, its characters filled with a gradient that repeats 100 times along +/// a vector `len` pt long. +fn spread_gradient_type(s: &mut Session, len: f64) -> u64 { + let t = run(s, "text.create", json!({"x": 50, "y": 100, "text": "Hi", "size": 48}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [t]})); + run( + s, + "paint.setFill", + json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [50, 100], "end": [50.0 + len, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}}), + ); + t +} + +/// How many objects in the document have a gradient fill, and how many are type. +fn gradients_and_type(s: &Session) -> (usize, usize) { + let (mut gradients, mut texts) = (0, 0); + s.doc().unwrap().doc.walk(|n| { + gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_))); + texts += usize::from(matches!(n.kind, NodeKind::Text(_))); + }); + (gradients, texts) +} + +/// Type is outlined before its fills expand, and each glyph's gradient fits that glyph's own box: +/// `object.expand.info` reports a gradient (and a fill) only when a glyph's box needs no more runs +/// than are written, so Expand turns it into art. Both Expand Gradient To options leave the +/// glyphs whose gradient needs more runs with their gradient, and Object still outlines the type. +/// The query leaves the document and its history as they are. +#[test] +fn type_whose_gradient_needs_more_runs_than_written_reports_no_gradient() { + // 0.2 pt runs (100 repeats over 20 pt) across glyphs about 30 pt wide: under the limit. + // 0.02 pt runs (over 2 pt): far over it. + for (len, expands) in [(20.0, true), (2.0, false)] { + let mut s = session(); + spread_gradient_type(&mut s, len); + let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.layers.clone()); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone(), info["object"].clone()), (json!(expands), json!(expands), json!(true)), "{len}"); + assert!( + before == (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.layers.clone()), + "{len}: the query changed the document" + ); + for to in [json!({}), json!({"gradient": "mesh"})] { + let mut s = session(); + spread_gradient_type(&mut s, len); + run(&mut s, "object.expand", to.clone()); + let (gradients, texts) = gradients_and_type(&s); + assert_eq!(texts, 0, "{len} {to}: outlined"); + assert_eq!(gradients == 0, expands, "{len} {to}: {gradients} gradient fills left"); + } + } +} + +/// One glyph whose box needs more runs than are written does not hide another that Expand turns +/// into art: the 200 pt "H" keeps its gradient, the 48 pt "i" expands, and the query reports it. +#[test] +fn type_reports_a_gradient_when_any_glyph_expands() { + let mut s = session(); + let t = spread_gradient_type(&mut s, 20.0); + run(&mut s, "text.setRangeStyle", json!({"id": t, "start": 0, "end": 1, "size": 200})); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(true), json!(true))); + run(&mut s, "object.expand", json!({})); + assert_eq!(gradients_and_type(&s), (1, 0), "the large glyph keeps its gradient"); +} + +/// The same rule for type in the art of a symbol instance that Object expands. The symbol's art +/// is the type scaled down (its "i" about 2.5 pt wide), so its glyphs need more runs than are +/// written with 0.002 pt runs (over a 0.2 pt vector). +#[test] +fn type_in_symbol_art_reports_a_gradient_only_when_it_expands() { + for (len, expands) in [(20.0, true), (0.2, false)] { + let mut s = session(); + spread_gradient_type(&mut s, len); + let inst = run(&mut s, "symbol.new", json!({"name": "Hi"}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [inst]})); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!(info["gradient"], json!(expands), "{len}"); + assert_eq!((info["symbolArt"]["gradient"].clone(), info["symbolArt"]["fill"].clone()), (json!(expands), json!(expands)), "{len}: {info}"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/engine/src/tests/pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs index 7de08463f3..ecdf140c0e 100644 --- a/crates/engine/src/tests/pdfpresets.rs +++ b/crates/engine/src/tests/pdfpresets.rs @@ -123,40 +123,49 @@ fn presets_export_and_import_round_trip() { assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export"); } -/// `longPagesInPoints` is saved with a preset, comes back through a presets file and the -/// preferences, shows in `document.pdfSettings`, and a 10 m page exported with it reopens at 10 m. -/// A value that isn't a boolean is refused. +/// `longPagesInPoints` is on by default: a 10 m page exported without options is written in +/// points and reopens at 10 m. Turned off, it is saved with a preset, comes back through a presets +/// file and the preferences, shows in `document.pdfSettings`, and the page exported with it has a +/// `UserUnit` and reopens at 10 m. A value that isn't a boolean is refused. #[test] fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 28_346.456_692_913_386, "height": 2_834.645_669_291_338_5})).unwrap(); - s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": true, "preserveEditing": false})).unwrap(); - assert!(s.prefs.pdf_presets[0].settings.long_pages_in_points); + let reopened = |bytes: &[u8]| { + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + let rect = o.doc().unwrap().doc.artboards[0].rect; + assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + }; + let warned = |v: &Value, end: &str| v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().ends_with(end)); + // By default the page is written in points. + assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(true)); + let v = s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap(); + let bytes = pdf(&v); + assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); + assert!(warned(&v, "written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in"), "{v}"); + reopened(&bytes); + // Off in a preset. + s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": false, "preserveEditing": false})).unwrap(); + assert!(!s.prefs.pdf_presets[0].settings.long_pages_in_points); let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); assert_eq!(back.pdf_presets, s.prefs.pdf_presets); let text = s.execute("pdf.preset.export", &json!({"names": ["Banner"]})).unwrap()["data"].as_str().unwrap().to_string(); let mut t = session(); t.execute("pdf.preset.import", &json!({"data": text})).unwrap(); assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets); - let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": true})).unwrap(); - assert_eq!((&v["settings"]["longPagesInPoints"], &v["changed"]), (&json!(true), &json!([{"option": "longPagesInPoints", "value": true}])), "{v}"); - assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(false)); - // The preset writes the page in points; the file reopens at 10 m. - let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); - let bytes = pdf(&v); - assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); - assert!( - v["warnings"] - .as_array() - .unwrap() - .iter() - .any(|w| w.as_str().unwrap().ends_with("written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in")), + let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": false})).unwrap(); + assert_eq!( + (&v["settings"]["longPagesInPoints"], &v["changed"]), + (&json!(false), &json!([{"option": "longPagesInPoints", "value": false}])), "{v}" ); - let mut o = Session::new(); - o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - let rect = o.doc().unwrap().doc.artboards[0].rect; - assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + // The preset writes the page with a `UserUnit`; the file reopens at 10 m. + let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); + let bytes = pdf(&v); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit 1.969")); + assert!(warned(&v, "it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller"), "{v}"); + reopened(&bytes); for bad in [json!("yes"), json!(1)] { assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}"); assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Bad", "longPagesInPoints": bad}))), "{bad}"); diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index a0a8ededa0..8dbe06f349 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -365,6 +365,95 @@ fn a_file_read_apart_from_the_session_replaces_what_was_selected_when_place_star } } +/// Every open document with its art, selection and undo steps, and the journal, tool and tool +/// options: what a refused hand-off leaves as it was. +type Snapshot = (Vec<(u64, Arc, Vec, usize)>, usize, &'static str, Value); + +fn snapshot(s: &Session) -> Snapshot { + let docs = s.documents().iter().map(|d| (d.uid, d.doc.clone(), d.selection.objects.clone(), d.history.undo.len())).collect(); + (docs, s.journal.len(), s.tool_id(), s.tool_options()) +} + +/// A file read apart from the session is placed only in the document Place started in: with +/// another document active, Replace included when that document holds the same object under the +/// same id, or after the document closed, nothing changes in any document; with the document +/// active again, the file is placed in it. +#[test] +fn a_file_read_apart_from_the_session_is_placed_only_in_the_document_it_was_read_for() { + let blue = file("blue.png", &png(8, 8, [0, 0, 255, 255], None)); + let not_active = "the document it was prepared for is no longer active"; + for replace in [false, true] { + let p = with(blue.clone(), json!({"replace": replace})); + let mut s = session(); + let square = json!({"x": 10, "y": 10, "width": 20, "height": 20}); + let a = s.execute("shape.rectangle", &square).unwrap()["id"].clone(); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let b = s.execute("shape.rectangle", &square).unwrap()["id"].clone(); + assert_eq!(a, b, "the other document holds an object with the same id"); + assert!(node(&s, &b) == s.documents()[0].doc.node(NodeId(a.as_u64().unwrap())).unwrap().clone(), "and the same content"); + let before = snapshot(&s); + let err = s.place_read(read).unwrap_err(); + assert!(err.to_string().contains(not_active), "replace {replace}: {err}"); + assert!(snapshot(&s) == before, "replace {replace}: nothing placed, selected or journaled in either document"); + // Active again, the document Place started in takes the file. + let read = { + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.prepare_place(&p).unwrap().read().unwrap() + }; + s.execute("document.activate", &json!({"index": 1})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + let r = s.place_read(read).unwrap(); + assert_eq!(s.active_index(), Some(0)); + assert!(s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).is_some(), "replace {replace}: placed in the first document"); + let others = s.documents()[1].doc.layers.last().unwrap().children().unwrap().len(); + assert_eq!(others, 1, "replace {replace}: the other document keeps its one object"); + } + // The document Place started in closed: refused in the document left open, and with none left. + let mut s = session(); + let read = s.prepare_place(&blue).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("file.close", &json!({"index": 0})).unwrap(); + let before = snapshot(&s); + assert!(s.place_read(read).unwrap_err().to_string().contains(not_active)); + assert!(snapshot(&s) == before, "nothing placed in the document left open"); + let read = s.prepare_place(&blue).unwrap().read().unwrap(); + s.execute("file.close", &json!({})).unwrap(); + let journal = s.journal.len(); + assert!(s.place_read(read).is_err() && s.journal.len() == journal, "no document left: refused"); +} + +/// Files read for the place cursor apart from the session load it only in the document they were +/// read for: with another document active, or after that document closed, its tool and queue stay +/// as they were; with the document active again, the cursor is loaded. +#[test] +fn files_read_for_the_place_cursor_apart_from_the_session_load_it_only_in_their_document() { + let p = json!({"files": [file("a.png", &png(40, 20, [9, 9, 9, 255], None))], "link": false}); + let not_active = "the document it was prepared for is no longer active"; + let mut s = session(); + let read = s.prepare_place_queue(&p).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let before = snapshot(&s); + assert!(s.place_queue_read(read).unwrap_err().to_string().contains(not_active)); + assert!(snapshot(&s) == before && s.tool_id() == "selection", "the other document's tool and queue stay"); + let read = { + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.prepare_place_queue(&p).unwrap().read().unwrap() + }; + s.execute("document.activate", &json!({"index": 1})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.place_queue_read(read).unwrap()["count"], 1); + assert_eq!(s.tool_id(), "place"); + // The document they were read for closed. + let mut s = session(); + let read = s.prepare_place_queue(&p).unwrap().read().unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("file.close", &json!({"index": 0})).unwrap(); + let before = snapshot(&s); + assert!(s.place_queue_read(read).unwrap_err().to_string().contains(not_active)); + assert!(snapshot(&s) == before, "the document left open keeps its tool"); +} + /// A linked image keeps the preview its file was read with ([`cmd::place::PlaceJob::read`] makes /// it), so placing the read file does not decode the image again; an embedded image has none. #[test] @@ -662,6 +751,101 @@ fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() { assert!(linked, "still linked to the swatch it brought"); } +/// The symbol and pattern art placed art brings takes ids of the document's own: the file's ids +/// already name other objects there, and the Links panel names linked art in symbols by its id. +#[test] +fn placed_art_brings_its_symbols_and_patterns_with_ids_of_the_documents_own() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + // The document's objects take the same ids the file's do. + let mut s = session(); + for x in [0, 20, 40, 60] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 5, "height": 5})).unwrap(); + } + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let doc = &s.doc().unwrap().doc; + assert!(doc.symbols.iter().any(|x| x.name == "Star") && doc.pattern("Dots").is_some()); + let mut ids = vec![]; + doc.visit_stored(|n, _| ids.push(n.id.0)); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); +} + +/// Placing the same file again shares the symbols and patterns the first placement brought: their +/// art differs from the file's only in its ids, so no `Star 2` or `Dots 2` joins the document. +#[test] +fn placing_the_same_art_again_shares_its_symbols_and_patterns() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + let counts = |s: &Session| { + let doc = &s.doc().unwrap().doc; + (doc.symbols.len(), doc.patterns.len()) + }; + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let once = counts(&s); + for _ in 0..2 { + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + } + assert_eq!(counts(&s), once); + let doc = &s.doc().unwrap().doc; + assert!(!doc.symbols.iter().any(|x| x.name == "Star 2") && doc.pattern("Dots 2").is_none()); +} + +/// A symbol whose art matches the document's own but uses a pattern the document defines +/// differently is not shared: it comes in under a free name, using the pattern it brings. +#[test] +fn placed_art_does_not_share_a_symbol_whose_pattern_moves() { + let mut src = session(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star", "ids": [tile.0]})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + // The document's "Dots" changes; its "Star" still matches the file's. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + let dots = d.pattern_mut("Dots").unwrap(); + dots.tile = Rect::new(0.0, 0.0, 50.0, 50.0); + let r = place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let mut symbol = None; + node(&s, &r["ids"][0]).walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol: name, .. } = &n.kind { + symbol = Some(name.clone()); + } + }); + assert_eq!(symbol.as_deref(), Some("Star 2")); + let doc = &s.doc().unwrap().doc; + let star2 = doc.symbols.iter().find(|x| x.name == "Star 2").unwrap(); + let mut fills = vec![]; + star2.art.walk(&mut |n| { + fills.extend(n.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }, + _ => None, + })) + }); + assert_eq!(fills, ["Dots 2"]); +} + #[test] fn placed_art_brings_its_brushes_renaming_on_conflict() { // The file: a path with its own brush, and one with a brush the defaults hold. diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 2661e85787..499f2d15c6 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -796,30 +796,269 @@ fn expanding_a_pattern_fill_keeps_each_piece_s_own_paint() { assert_eq!(patterns, 0, "the tiles are objects"); } -/// Two painted effects on one fill, the first returning a piece with two fills: the second replaces -/// the first of them and the other fill stays over it. +/// A path's bounds and the paints of its fills and strokes, each stroke with its weight. +type PaintedPath = (Option, Vec<(Paint, Option)>); + +/// The paths of `id`'s reshaped art that paint, in paint order, each with the fills and strokes of +/// it that paint. +fn painted_paths(s: &Session, id: NodeId) -> Vec { + let st = s.doc().unwrap(); + let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); + let mut paths = vec![]; + art.walk(&mut |n| { + if let Some(p) = n.path_data() { + let items: Vec<_> = n + .appearance + .items + .iter() + .map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => (f.paint.clone(), None), + vectorcraft_doc::AppearanceItem::Stroke(st) => (st.paint.clone(), Some(st.width)), + }) + .filter(|(paint, _)| !paint.is_none()) + .collect(); + if !items.is_empty() { + paths.push((p.bounds(), items)); + } + } + }); + paths +} + +fn paint(hex: &str) -> Paint { + Paint::solid(Color::from_hex(hex).unwrap()) +} + +/// Two painted effects on one fill, the first returning a piece with two fills: the second runs on +/// each of them, told which fill it is on. On the second fill it returns a blue square at 80 pt; on +/// the first it returns a result that is refused, which leaves only that fill, red, on the square at +/// 40 pt. #[test] -fn a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills() { +fn a_later_painted_effect_runs_on_every_fill_a_piece_returns() { let mut s = session(); let id = rect(&mut s, 10.0, "#ff0000"); let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); - let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &two)); - install_wat(&mut s, &tpl::returning(&painting("test.engine.onefill", true), &one)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.onsecond", true), tpl::Given::Params, r#""item":{"fill":1}"#, &blue)); let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twofills", "item": fill})); - run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onefill", "item": fill})); - assert_eq!(drawn(&s).pixel(50, 50), GREEN, "the other fill paints over the replaced one"); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onsecond", "item": fill})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (RED, BLUE), "the first fill as it was, the second moved and blue"); + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#ff0000"), None)]), (b, vec![(paint("#0000ff"), None)])]); + // The same result for both fills: each is replaced. + let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.onsecond", true), &one)); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#0000ff"), None)]), (a, vec![(paint("#0000ff"), None)])]); +} + +/// On a stroke, a later painted effect runs on each stroke the first returns, with its own weight +/// and place among them, and keeps their order. +#[test] +fn a_later_painted_effect_runs_on_every_stroke_a_piece_returns() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"})); + let two = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#ff0000"), "width": 2}, {"paint": solid("#00ff00"), "width": 6}]}]}) + .to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "strokes": [{"paint": solid("#0000ff"), "width": 4}]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twostrokes", true), &two)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.onsecondstroke", true), tpl::Given::Params, r#""item":{"stroke":1}"#, &blue)); + let stroke = node(&s, id).appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.twostrokes", "plugin.test.engine.onsecondstroke"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": stroke})); + } + let stroked: Vec<_> = painted_paths(&s, id).into_iter().filter(|(_, items)| items.iter().any(|(_, w)| w.is_some())).collect(); + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(stroked, [(a, vec![(paint("#ff0000"), Some(2.0))]), (b, vec![(paint("#0000ff"), Some(4.0))])]); +} + +/// A third painted effect runs on every fill the second returned, for each fill the first returned. +#[test] +fn a_third_painted_effect_visits_every_fill_from_the_second() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let first = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let second = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff"), solid("#00ffff")]}]}).to_string(); + let third = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#ffff00")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.third.a", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.third.b", true), &second)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.third.c", true), tpl::Given::Params, r#""item":{"fill":1}"#, &third)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["a", "b", "c"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.test.engine.third.{effect}"), "item": fill})); + } + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + let pair = [(a, vec![(paint("#0000ff"), None)]), (b, vec![(paint("#ffff00"), None)])]; + assert_eq!(painted_paths(&s, id), [pair.clone(), pair].concat()); +} + +/// A later live effect that changes geometry only runs on each fill a painted effect returned, told +/// which fill it is on: on the second it moves the square to 80 pt, and the first stays at 40 pt. +#[test] +fn a_later_geometry_effect_uses_each_generated_fill_s_context() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let moved = json!({"objects": [{"path": square_at(80.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.geomfills", true), &two)); + install_wat(&mut s, &tpl::when_given(&painting("test.engine.geomsecond", false), tpl::Given::Params, r#""item":{"fill":1}"#, &moved)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.geomfills", "plugin.test.engine.geomsecond"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let (a, b) = (square_at(40.0).bounds(), square_at(80.0).bounds()); + assert_eq!(painted_paths(&s, id), [(a, vec![(paint("#ff0000"), None)]), (b, vec![(paint("#00ff00"), None)])]); +} + +/// A later Outline Stroke effect runs on each stroke a painted effect returned, with that stroke's +/// weight: the 2 pt stroke's outline is 22 pt wide and the 6 pt stroke's 26 pt, in their order. +#[test] +fn a_later_outline_stroke_uses_each_generated_stroke_s_weight() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"})); + let two = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#ff0000"), "width": 2}, {"paint": solid("#00ff00"), "width": 6}]}]}) + .to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.outlinestrokes", true), &two)); + let stroke = node(&s, id).appearance.items.iter().position(|i| !i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.outlinestrokes", "item": stroke})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "path.outlineStroke", "params": {}, "item": stroke})); let st = s.doc().unwrap(); let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); - let mut paints = vec![]; + // Each outline is a compound path (the ring around the square): its width and its strokes. + let mut outlines = vec![]; art.walk(&mut |n| { - if n.path_data().is_some() { - paints.push(n.appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect::>()); + if let vectorcraft_doc::NodeKind::Compound { children, .. } = &n.kind { + let bounds = children.iter().filter_map(|c| c.path_data().and_then(PathData::bounds)).reduce(|a, b| a.union(b)); + let strokes: Vec<_> = n + .appearance + .items + .iter() + .filter_map(|i| match i { + vectorcraft_doc::AppearanceItem::Stroke(st) => Some((st.paint.clone(), st.width)), + vectorcraft_doc::AppearanceItem::Fill(_) => None, + }) + .collect(); + outlines.push((bounds.map(|b| (b.width() * 1000.0).round() / 1000.0), strokes)); } }); - let fill = |hex: &str| Paint::solid(Color::from_hex(hex).unwrap()); - assert!(paints.contains(&vec![fill("#0000ff"), fill("#00ff00")]), "{paints:?}"); + assert_eq!( + outlines, + [(Some(22.0), vec![(paint("#ff0000"), 2.0)]), (Some(26.0), vec![(paint("#00ff00"), 6.0)])], + "each stroke is outlined at its own weight" + ); +} + +/// A later painted effect's runs on the fills a piece returned share the effects' budget: each run +/// that fits gives all its pieces, and once the budget is spent the remaining fills stay together +/// on the piece's path, with none of their run's pieces. +#[test] +fn painted_fills_share_the_budget_without_partial_results() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let fills = vec![solid("#ff0000"); vectorcraft_plugins::objects::MAX_ITEMS]; + let first = json!({"objects": [{"path": square_at(40.0), "fills": fills}]}).to_string(); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let many = json!({"objects": vec![json!({"path": tiny}); 400]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.budget.fills", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.budget.many", true), &many)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.budget.fills", "plugin.test.engine.budget.many"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let paths = painted_paths(&s, id); + let tinies = paths.iter().filter(|(b, _)| *b == tiny.bounds()).count(); + let kept: Vec<_> = paths.iter().filter(|(b, _)| *b == square_at(40.0).bounds()).collect(); + assert_eq!(kept.len(), 1, "the fills left out stay together"); + let ran = vectorcraft_plugins::objects::MAX_ITEMS - kept[0].1.len(); + assert!(ran > 1 && !kept[0].1.is_empty(), "{ran} runs gave their pieces"); + assert_eq!(tinies, ran * 400, "every run that fit gave all its pieces"); + assert!(paths.len() <= vectorcraft_plugins::objects::MAX_OBJECTS, "{} paths", paths.len()); +} + +/// A later painted effect runs on each of 256 fills a painted effect returned on an 8,000-anchor +/// path, although 256 copies of that path would pass the effects' budget, when what it returns +/// fits. +#[test] +fn a_later_painted_effect_runs_on_many_fills_of_a_large_piece_when_its_results_fit() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let zigzag: Vec = (0..8_000).map(|i| Point::new(40.0 + f64::from(i) * 20.0 / 8_000.0, if i % 2 == 0 { 40.0 } else { 60.0 })).collect(); + let large = PathData::single(SubPath::polyline(&zigzag, true)); + let fills = vec![solid("#ff0000"); vectorcraft_plugins::objects::MAX_ITEMS]; + let first = json!({"objects": [{"path": large, "fills": fills}]}).to_string(); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let small = json!({"objects": [{"path": tiny}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.large.fills", true), &first)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.large.small", true), &small)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.large.fills", "plugin.test.engine.large.small"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let paths = painted_paths(&s, id); + let tinies = paths.iter().filter(|(b, _)| *b == tiny.bounds()).count(); + assert_eq!(tinies, vectorcraft_plugins::objects::MAX_ITEMS, "every fill's run"); + assert!(paths.iter().all(|(b, _)| *b != large.bounds()), "no fill left on the large path"); +} + +/// 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); + vectorcraft_geom::Anchor::with_handles(Point::new(x, 0.0), Point::new(x, -h), Point::new(x, h)) + }) + .collect(); + PathData::single(SubPath::new(anchors, false)) +} + +/// A later painted effect's run on the first of two fills a painted effect returned on a path that +/// takes most of the effects' budget gives its result when it and the second fill's result fit +/// together, although the result and another copy of the path would not. +#[test] +fn a_later_painted_run_counts_the_results_after_it_before_keeping_room_for_its_input() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let (large, result) = (tall_curves(1_000), tall_curves(600)); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let two = json!({"objects": [{"path": large, "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let on_first = json!({"objects": [{"path": result}]}).to_string(); + let on_second = json!({"objects": [{"path": tiny}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.tall.fills", true), &two)); + let second = painting("test.engine.tall.each", true); + install_wat(&mut s, &tpl::when_given_else(&second, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_second)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.tall.fills", "plugin.test.engine.tall.each"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let want = [(result.bounds(), vec![(paint("#ff0000"), None)]), (tiny.bounds(), vec![(paint("#00ff00"), None)])]; + assert_eq!(painted_paths(&s, id), want, "both runs give their results"); +} + +/// When the results of a later painted effect's runs on the two fills of a large path do not fit +/// together, and the first fill's result and another copy of the path do not fit either, the first +/// fill stays on the path and the second fill's result, which fits beside the path, is drawn. +#[test] +fn a_later_painted_run_that_did_not_fit_beside_an_earlier_result_fits_once_that_is_left_out() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let (large, first, second) = (tall_curves(577), tall_curves(1_058), tall_curves(770)); + let two = json!({"objects": [{"path": large, "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let on_first = json!({"objects": [{"path": first}]}).to_string(); + let on_second = json!({"objects": [{"path": second}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.refit.fills", true), &two)); + let each = painting("test.engine.refit.each", true); + install_wat(&mut s, &tpl::when_given_else(&each, tpl::Given::Params, r#""item":{"fill":0}"#, &on_first, &on_second)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for effect in ["plugin.test.engine.refit.fills", "plugin.test.engine.refit.each"] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": fill})); + } + let want = [(large.bounds(), vec![(paint("#ff0000"), None)]), (second.bounds(), vec![(paint("#00ff00"), None)])]; + assert_eq!(painted_paths(&s, id), want, "the second fill's result is drawn"); } /// A Transform effect before a painted effect on a fill moves the fill's placed gradient: a piece @@ -1183,6 +1422,39 @@ fn the_first_view_defers_plug_in_effects_past_its_budget() { assert_eq!(fx_c.take_report(), Some(0)); } +/// A later painted effect deferred on the fills a piece returned is listed once, at its place among +/// the object's fill's effects, and a refresh of the object runs it on each of them. +#[test] +fn deferred_runs_on_the_fills_a_piece_returned_keep_the_fill_s_stage() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.two", true), &two)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.deferred.later", true), &blue)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.deferred.two", "item": fill})); + // The first effect's result is kept from a drawing outside a view's budget. + assert_eq!(drawn(&s).pixel(50, 50), GREEN); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.deferred.later", "item": fill})); + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + let img = fx.view(|| drawn(&s)); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (GREEN, WHITE), "the fills on the first effect's piece"); + let places = run(&mut s, "effect.deferredStatus", json!({}))["effects"] + .as_array() + .unwrap() + .iter() + .map(|e| (e["objectId"].as_u64().unwrap(), e["item"].as_u64(), e["effectIndex"].as_u64().unwrap())) + .collect::>(); + assert_eq!(places, [(id.0, Some(fill as u64), 1)], "the fill's stage, listed once"); + step.store(0, std::sync::atomic::Ordering::Relaxed); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": [id.0]})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + let img = fx.view(|| drawn(&s)); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50)), (WHITE, BLUE), "both fills replaced"); +} + /// Deferred stages are listed where their effects are: after an effect that changes no geometry, /// and on a fill after a Transform effect that reaches its appearance, each of two instances of /// one plug-in at its own place. diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 4f5f093f74..1010d9b547 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -784,6 +784,9 @@ fn precise_cursors_replace_every_pen_state() { Cursor::LivePaintBucket, Cursor::LivePaintBucketFace, Cursor::LivePaintBucketEdge, + Cursor::LivePaintSelectFace, + Cursor::LivePaintSelectEdge, + Cursor::LivePaintSelectOutside, ] { assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); } @@ -2826,3 +2829,33 @@ fn snap_to_glyph_options_read_set_and_reset() { s.execute("prefs.reset", &json!({})).unwrap(); assert_eq!(s.execute("view.snapToGlyph.options", &json!({})).unwrap(), all); } + +/// Pixel Snapping Options: `view.snapToPixel.options` reads and sets While Drawing, While Moving +/// and While Scaling, all on by default, kept as the `pixelSnap` preference group; bad keys and +/// values change nothing, preferences saved without the group read it as all on, and resetting the +/// preferences turns them all back on. +#[test] +fn pixel_snapping_options_read_set_and_reset() { + let mut s = Session::new(); + let all = json!({"drawing": true, "moving": true, "scaling": true}); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), all); + let set = json!({"drawing": true, "moving": false, "scaling": true}); + assert_eq!(s.execute("view.snapToPixel.options", &json!({"moving": false})).unwrap(), set); + assert_eq!(s.execute("prefs.get", &json!({"key": "pixelSnap"})).unwrap(), set); + assert!(!s.prefs.pixel_snap.moving && s.prefs.pixel_snap.drawing && s.prefs.pixel_snap.scaling); + for bad in [json!({"moving": 1}), json!({"rotating": false})] { + assert!(s.execute("view.snapToPixel.options", &bad).is_err(), "{bad}"); + assert!(s.execute("prefs.set", &json!({"key": "pixelSnap", "value": bad})).is_err(), "{bad}"); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), set, "{bad}"); + } + s.execute("prefs.set", &json!({"key": "pixelSnap", "value": {"scaling": false}})).unwrap(); + let both = json!({"drawing": true, "moving": false, "scaling": false}); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), both); + // Preferences written before the group existed, and a group that names only some options. + let older: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap(); + assert_eq!(older.pixel_snap, vectorcraft_tools::PixelSnap::default()); + let partial: Prefs = serde_json::from_value(json!({"pixelSnap": {"drawing": false}})).unwrap(); + assert_eq!(partial.pixel_snap, vectorcraft_tools::PixelSnap { drawing: false, moving: true, scaling: true }); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.execute("view.snapToPixel.options", &json!({})).unwrap(), all); +} diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 5edd0d7fbd..99febca6b2 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -789,3 +789,29 @@ fn units_and_ruler_guides_survive_save_and_reopen() { } let _ = std::fs::remove_dir_all(d); } + +/// A ruler guide hidden or locked on its own (the Layers panel's eye and lock on its row, +/// `layer.setProps` on its node) stays so through Save, Close and Open, and the other guide stays +/// visible and unlocked. +#[test] +fn a_guide_hidden_or_locked_on_its_own_survives_save_and_reopen() { + let d = dir("guide-flags"); + for ext in ["vectorcraft", "pdf", "ai", "eps"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + for pos in [10, 20, 30] { + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + rect(&mut s); + let ids: Vec = s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.id.unwrap().0).collect(); + s.execute("layer.setProps", &json!({"ids": [ids[1]], "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [ids[2]], "locked": true})).unwrap(); + let p = path(&d, &format!("a.{ext}")); + s.execute("document.save", &json!({"path": p})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + s.execute("document.open", &json!({"path": p})).unwrap(); + let flags: Vec<_> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.pos, g.hidden, g.locked)).collect(); + assert_eq!(flags, [(10.0, false, false), (20.0, true, false), (30.0, false, true)], "{ext}"); + } + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index 57ff9b15cd..849d5b9080 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -63,7 +63,7 @@ fn each_member_has_its_value() { ("$.memCache", json!(0)), ("$.os", json!(os)), ("$.screens.length", json!(0)), - ("$.stack", json!("[global]:1\n")), + ("$.stack", json!("[global]@t.js:1\n")), ("$.strict", json!(false)), ("$.version", json!(version)), ("typeof $.getenv + typeof $.setenv + typeof $.sleep", json!("functionfunctionfunction")), @@ -165,7 +165,7 @@ fn getenv_reads_only_its_names_and_setenv_changes_only_the_run() { fn error_line_and_stack_follow_the_running_code() { let mut s = Session::new(); let source = "try { null.x; } catch (e) {}\nfunction where() {\n return [$.line, $.stack, $.error instanceof TypeError];\n}\nvc.log(where());"; - assert_eq!(logged(&mut s, source)[0], json!([3, "[global]:5\nwhere:3\n", true])); + assert_eq!(logged(&mut s, source)[0], json!([3, "[global]@t.js:5\nwhere@t.js:3\n", true])); assert_eq!(value(&mut s, "$.error"), json!(null), "each run starts without one"); } diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index dd27d9ff1f..a25b0489ba 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -276,6 +276,44 @@ fn an_engine_keeps_the_newest_places_within_its_budget() { assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept"); } +/// `$.line` and `$.stack` in code an earlier run of a named engine compiled give the line of that +/// run's file: its script, or a file it included; a script given as text is named by that run's +/// name. Code `eval` and `Function` compiled keeps its own line, in `[eval]`, and code whose place +/// the engine did not keep is at line 0 of `[unknown]`. +#[test] +fn the_line_and_the_stack_of_code_an_earlier_run_kept_are_at_that_runs_files() { + use super::script_include::short_stack; + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-line-stack-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("inc.jsxinc"), "\n\nfunction fromInclude() {\n return [$.line, $.stack];\n}").unwrap(); + let def = base.join("def.jsx"); + std::fs::write(&def, "#targetengine e\nvar pad = 1;\n#include \"inc.jsxinc\"\nfunction fromMain() {\n return fromInclude();\n}").unwrap(); + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + run(&mut s, "#targetengine e\n\nfunction fromText() {\n return $.stack;\n}").unwrap(); + let call = |s: &mut Session, code: &str| { + let r = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"})); + r.unwrap_or_else(|e| panic!("{code}: {e}"))["log"][0].clone() + }; + let r = call(&mut s, "vc.log(fromMain());"); + assert_eq!(r[0], 4, "{r}"); + assert_eq!(short_stack(&r[1]), "[global]@caller.js:3\nfromMain@def.jsx:5\nfromInclude@inc.jsxinc:4\n"); + assert_eq!(short_stack(&call(&mut s, "vc.log(fromText());")), "[global]@caller.js:3\nfromText@t.js:4\n"); + assert_eq!(call(&mut s, "vc.log(eval('\\n\\n$.line'));"), 3); + assert_eq!(call(&mut s, "vc.log(eval('\\n$.stack'));"), "[global]@caller.js:3\n[global]@[eval]:2\n"); + let r = call(&mut s, "vc.log(new Function('\\nreturn $.stack;')());"); + assert_eq!(r, "[global]@caller.js:3\n[anonymous]@[eval]:4\n", "the line of the function source `Function` compiles"); + // A map larger than the budget is not kept. + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1))); + let lost = run(&mut s, "#targetengine e\n\n\nfunction lost() {\n return [$.line, $.stack];\n}"); + let r = lost.map(|_| call(&mut s, "vc.log(lost());")); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + let r = r.unwrap(); + assert_eq!(r, json!([0, "[global]@caller.js:3\nlost@[unknown]:0\n"])); + let _ = std::fs::remove_dir_all(base); +} + /// A run of a named engine whose script does not compile defines no code, so it keeps no place /// and pushes out none of the places kept before. An included file without a preprocessor line is /// first parsed with the script it joins, so its syntax error ends the run as it starts. diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index 1bf259409d..1ad659cfdd 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -496,6 +496,49 @@ fn script_included_error_has_local_file_and_line() { let _ = std::fs::remove_dir_all(base); } +/// `$.stack`'s text with each frame's file cut to its name: `function@name:line` a line. +pub(crate) fn short_stack(stack: &Value) -> String { + let stack = stack.as_str().unwrap_or_else(|| panic!("a stack: {stack}")); + stack + .lines() + .map(|frame| match (frame.split_once('@'), frame.rsplit_once(':')) { + (Some((name, _)), Some((place, line))) => { + let file = place.split_once('@').map_or(place, |(_, f)| f).rsplit(['/', '\\']).next().unwrap_or_default(); + format!("{name}@{file}:{line}\n") + } + _ => format!("{frame}\n"), + }) + .collect() +} + +/// `$.line` and `$.stack` give the line of the file the running code is in: the script, a file it +/// included (and a file that one included) or a file `$.evalFile` gave and the files it included, +/// whatever lines come before it in the run's expanded source. +#[test] +fn script_line_and_stack_are_at_the_line_of_each_file() { + let base = folder("line-stack"); + write( + &base.join("main/lib.jsxinc"), + "\nfunction libWhere() {\n return [$.line, $.stack];\n}\nvar libLine = $.line;\n#include \"deep.jsxinc\"\n", + ); + write(&base.join("main/deep.jsxinc"), "\n\nvar deepLine = $.line;\nfunction deepStack() {\n return $.stack;\n}"); + write(&base.join("main/sub/ev.jsx"), "#include \"inc.jsxinc\"\nfunction evWhere() {\n return incWhere();\n}\nevWhere();"); + write(&base.join("main/sub/inc.jsxinc"), "\n\nfunction incWhere() {\n return [$.line, $.stack];\n}"); + let text = "var a = 1;\nvar b = 2;\n#include \"lib.jsxinc\"\nvar mainLine = $.line;\nvc.log([libLine, deepLine, mainLine]);\n\ + vc.log(libWhere());\nvc.log(deepStack());\nvc.log($.evalFile('sub/ev.jsx'));"; + let log = run(&base, text).unwrap()["log"].clone(); + assert_eq!(log[0], json!([5, 3, 4]), "{log}"); + assert_eq!(log[1][0], 3, "{log}"); + assert_eq!(short_stack(&log[1][1]), "[global]@main.jsx:6\nlibWhere@lib.jsxinc:3\n"); + assert_eq!(short_stack(&log[2]), "[global]@main.jsx:7\ndeepStack@deep.jsxinc:5\n"); + assert_eq!(log[3][0], 4, "{log}"); + assert_eq!(short_stack(&log[3][1]), "[global]@main.jsx:8\n[global]@ev.jsx:5\nevWhere@ev.jsx:3\nincWhere@inc.jsxinc:4\n"); + // The file a frame names is the one `$.fileName` gives while that file runs. + let log = run(&base, "vc.log($.stack.slice(9, -3) === $.fileName);").unwrap()["log"].clone(); + assert_eq!(log, json!([true])); + let _ = std::fs::remove_dir_all(base); +} + /// Pumps the run in the app until it ends or waits at a dialog (`None`). fn pump(s: &mut Session) -> Option> { for _ in 0..10_000 { diff --git a/crates/engine/src/tests/shortcutset.rs b/crates/engine/src/tests/shortcutset.rs index 8f216b8c00..fb5292e243 100644 --- a/crates/engine/src/tests/shortcutset.rs +++ b/crates/engine/src/tests/shortcutset.rs @@ -51,7 +51,7 @@ fn entries_keep_their_sections_and_the_command_counts_codes() { assert_eq!(r["codes"]["modifiers"], json!([{"value": 64, "count": 2}, {"value": 80, "count": 1}])); assert_eq!((r["unread"].as_array().unwrap().len(), r["truncated"].as_bool()), (0, Some(false)), "{r}"); // The same from a file, and from its bytes. - let dir = vectorcraft_testkit::temp_dir("shortcutset-read"); + let dir = vectorcraft_testkit::TempDir::new("shortcutset-read"); let path = dir.join("Studio Keys.kys"); std::fs::write(&path, text).unwrap(); let mut s = Session::new(); @@ -59,7 +59,6 @@ fn entries_keep_their_sections_and_the_command_counts_codes() { assert_eq!((r["set"].as_str(), r["entries"].as_array().unwrap().len()), (Some("Studio Keys"), 3)); let b64 = vectorcraft_format::base64_encode(text.as_bytes()); assert_eq!(s.execute("shortcutSet.read", &json!({ "dataBase64": b64 })).unwrap()["entries"].as_array().unwrap().len(), 3); - let _ = std::fs::remove_dir_all(dir); } #[test] @@ -124,7 +123,7 @@ fn caps_nesting_entries_and_names() { #[test] fn big_files_are_read_up_to_the_cap() { let text = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(5 << 20)); - let dir = vectorcraft_testkit::temp_dir("shortcutset-big"); + let dir = vectorcraft_testkit::TempDir::new("shortcutset-big"); let path = dir.join("Big.kys"); std::fs::write(&path, &text).unwrap(); let b64 = vectorcraft_format::base64_encode(text.as_bytes()); @@ -145,7 +144,6 @@ fn big_files_are_read_up_to_the_cap() { assert_eq!(fits.len(), (4 << 20) + 3); std::fs::write(&path, &fits).unwrap(); assert_eq!(s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap()["truncated"], false); - let _ = std::fs::remove_dir_all(dir); } fn matched(s: &mut Session, p: Value) -> Vec { diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs index 16d7f01993..dd10df1597 100644 --- a/crates/engine/src/tests/spelling.rs +++ b/crates/engine/src/tests/spelling.rs @@ -161,7 +161,7 @@ fn a_changed_word_replaces_the_old_one() { #[test] fn the_custom_dictionary_file_is_read_and_kept_up_to_date() { let _one_at_a_time = dictionary_lock(); - let dir = vectorcraft_testkit::temp_dir("spelling-dictionary"); + let dir = vectorcraft_testkit::TempDir::new("spelling-dictionary"); let file = dir.join("Custom Dictionary.txt"); std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap(); let mut s = session(); @@ -224,7 +224,7 @@ fn in_dictionary(s: &mut Session, word: &str) -> bool { #[test] fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() { let _one_at_a_time = dictionary_lock(); - let dir = vectorcraft_testkit::temp_dir("spelling-unreadable"); + let dir = vectorcraft_testkit::TempDir::new("spelling-unreadable"); // A folder where the file should be can't be read as one; a file past what craft-proof reads // would lose its other words if written back. let folder = dir.join("Custom Dictionary.txt"); @@ -248,7 +248,7 @@ fn a_change_that_cant_be_saved_is_taken_back() { let _one_at_a_time = dictionary_lock(); let mut s = session(); run(&mut s, "text.spelling.addWord", json!({"word": "wkqzvx"})); - let dir = vectorcraft_testkit::temp_dir("spelling-rollback"); + let dir = vectorcraft_testkit::TempDir::new("spelling-rollback"); let folder = dir.join("Custom Dictionary.txt"); std::fs::create_dir_all(&folder).unwrap(); let mut failing = session(); @@ -266,7 +266,7 @@ fn a_change_that_cant_be_saved_is_taken_back() { fn a_dictionary_edit_is_saved_whole_or_not_at_all() { let _one_at_a_time = dictionary_lock(); const EDIT: &str = "text.spelling.editDictionary"; - let dir = vectorcraft_testkit::temp_dir("spelling-edit"); + let dir = vectorcraft_testkit::TempDir::new("spelling-edit"); let file = dir.join("Custom Dictionary.txt"); std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap(); let mut s = session(); @@ -302,7 +302,7 @@ fn a_dictionary_edit_is_saved_whole_or_not_at_all() { fn a_word_another_window_saved_stays_when_this_save_fails() { let _one_at_a_time = dictionary_lock(); use std::os::unix::fs::PermissionsExt; - let dir = vectorcraft_testkit::temp_dir("spelling-readonly"); + let dir = vectorcraft_testkit::TempDir::new("spelling-readonly"); let file = dir.join("Custom Dictionary.txt"); std::fs::write(&file, "").unwrap(); let mut s = session(); diff --git a/crates/engine/src/tests/store.rs b/crates/engine/src/tests/store.rs index b6ac7b0201..21967473bb 100644 --- a/crates/engine/src/tests/store.rs +++ b/crates/engine/src/tests/store.rs @@ -7,10 +7,8 @@ use vectorcraft_color::cms; use crate::Session; use crate::store::{MAX_PRESET_FILE, file_stem, holds}; -fn fresh_dir(tag: &str) -> std::path::PathBuf { - let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - dir +fn fresh_dir(tag: &str) -> vectorcraft_testkit::TempDir { + vectorcraft_testkit::TempDir::new(&format!("store-{tag}")) } fn saved_names(s: &mut Session) -> Vec { @@ -20,7 +18,8 @@ fn saved_names(s: &mut Session) -> Vec { #[test] fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() { - let dir = fresh_dir("presets").join("PDF Presets"); + let root = fresh_dir("presets"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.attach_pdf_preset_store(dir.clone()); a.execute("pdf.preset.save", &json!({"name": "Press", "description": "for the printer", "preset": "Press Quality"})).unwrap(); @@ -42,7 +41,8 @@ fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() #[test] fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() { - let dir = fresh_dir("rename-fails").join("PDF Presets"); + let root = fresh_dir("rename-fails"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.attach_pdf_preset_store(dir.clone()); a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap(); @@ -59,7 +59,8 @@ fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() { #[test] fn a_rename_that_only_changes_case_keeps_one_file() { - let dir = fresh_dir("rename-case").join("PDF Presets"); + let root = fresh_dir("rename-case"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.attach_pdf_preset_store(dir.clone()); a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); @@ -80,7 +81,8 @@ fn file_names(dir: &std::path::Path) -> Vec { #[cfg(windows)] #[test] fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() { - let dir = fresh_dir("rename-case-fails").join("PDF Presets"); + let root = fresh_dir("rename-case-fails"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.attach_pdf_preset_store(dir.clone()); a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap(); @@ -103,7 +105,8 @@ fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() { #[cfg(not(any(windows, target_vendor = "apple")))] #[test] fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() { - let dir = fresh_dir("rename-case-blocked").join("PDF Presets"); + let root = fresh_dir("rename-case-blocked"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.attach_pdf_preset_store(dir.clone()); a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap(); @@ -118,7 +121,8 @@ fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() { #[test] fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() { - let dir = fresh_dir("seed").join("PDF Presets"); + let root = fresh_dir("seed"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.execute("pdf.preset.save", &json!({"name": "From Preferences"})).unwrap(); a.attach_pdf_preset_store(dir.clone()); @@ -155,7 +159,8 @@ fn a_write_reads_the_file_it_replaces_only_one_byte_past_the_new_length() { #[test] fn a_saved_preset_replaces_an_oversized_file_in_its_place() { - let dir = fresh_dir("oversized-put").join("PDF Presets"); + let root = fresh_dir("oversized-put"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.attach_pdf_preset_store(dir.clone()); a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); @@ -171,7 +176,8 @@ fn a_saved_preset_replaces_an_oversized_file_in_its_place() { #[test] fn damaged_unsafe_and_oversized_preset_files_are_skipped() { - let dir = fresh_dir("damaged").join("PDF Presets"); + let root = fresh_dir("damaged"); + let dir = root.join("PDF Presets"); let mut a = Session::new(); a.attach_pdf_preset_store(dir.clone()); a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap(); @@ -214,7 +220,8 @@ fn icc_named(name: &str) -> Vec { #[test] fn stored_profiles_register_again_and_removing_one_removes_its_copy() { - let dir = fresh_dir("profiles").join("Color Profiles"); + let root = fresh_dir("profiles"); + let dir = root.join("Color Profiles"); std::fs::create_dir_all(&dir).unwrap(); let name = "StoreRGB-1"; let file = format!("{}.icc", file_stem(name)); @@ -229,7 +236,8 @@ fn stored_profiles_register_again_and_removing_one_removes_its_copy() { // A profile loaded from elsewhere is copied in, whatever the file is called. let other = "StoreRGB-2"; - let loose = vectorcraft_testkit::temp_dir("store-loose").join("whatever.icm"); + let loose_dir = vectorcraft_testkit::TempDir::new("store-loose"); + let loose = loose_dir.join("whatever.icm"); std::fs::write(&loose, icc_named(other)).unwrap(); let r = s.execute("color.loadProfile", &json!({"path": loose.to_str().unwrap()})).unwrap(); assert_eq!((r["name"].as_str(), r["remembered"].as_bool()), (Some(other), Some(true))); diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index b380a4e06b..445ce38ea7 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -130,11 +130,9 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() { assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too"); } -/// A fresh scratch folder for one test. -fn temp_dir(name: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - d +/// A fresh scratch folder for one test, removed when the returned value is dropped. +fn temp_dir(name: &str) -> vectorcraft_testkit::TempDir { + vectorcraft_testkit::TempDir::new(&format!("swatchlib-{name}")) } #[test] diff --git a/crates/engine/src/tests/webexport.rs b/crates/engine/src/tests/webexport.rs index bdd474612f..cf427f8174 100644 --- a/crates/engine/src/tests/webexport.rs +++ b/crates/engine/src/tests/webexport.rs @@ -294,7 +294,7 @@ fn files_named_after_the_title_follow_windows_naming_rules() { #[test] fn files_are_written_beside_the_path() { - let dir = vectorcraft_testkit::temp_dir("webexport"); + let dir = vectorcraft_testkit::TempDir::new("webexport"); let mut s = two_colours(); let path = dir.join("art.gif").to_string_lossy().to_string(); let r = run(&mut s, "document.exportForWeb", json!({"path": path})); @@ -324,7 +324,7 @@ fn an_export_says_when_an_image_could_not_be_decoded() { let r = run(&mut s, "document.exportForWeb.preview", json!({"format": format})); assert!(undecoded(&r), "{format} preview: {}", r["warnings"]); } - let dir = vectorcraft_testkit::temp_dir("webexport-undecoded"); + let dir = vectorcraft_testkit::TempDir::new("webexport-undecoded"); let page = dir.join("page.html").to_string_lossy().to_string(); let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html"})); assert!(undecoded(&r), "{r}"); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index 8e3830c81c..7de9793525 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -600,6 +600,35 @@ fn free_transform_distort_mode_via_pointer() { assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort")); } +/// With Constrain on, a Free Distort corner keeps to the diagonal through the opposite corner, in +/// one undo step that undoes and redoes whole. +#[test] +fn free_transform_constrained_distort_keeps_the_corner_on_its_diagonal() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + let v = ViewInfo::default(); + s.select_tool("freeTransform", v).unwrap(); + s.set_tool_option("mode", &json!("distort")); + s.set_tool_option("constrain", &json!(true)); + let before = undo_len(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 230.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 340.0, 260.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 260.0), v).unwrap(); + let pts = |s: &Session| -> Vec { s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect() }; + let has_all = |pts: &[Point], want: [(f64, f64); 4]| want.iter().all(|&(x, y)| pts.iter().any(|q| q.distance(Point::new(x, y)) < 1e-6)); + // (40, 60) along the diagonal (200, 100) is 0.28 of it, (56, 28). + let distorted = [(100.0, 100.0), (300.0, 100.0), (356.0, 228.0), (100.0, 200.0)]; + let original = [(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)]; + assert!(has_all(&pts(&s), distorted), "{:?}", pts(&s)); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(has_all(&pts(&s), original), "{:?}", pts(&s)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(has_all(&pts(&s), distorted), "{:?}", pts(&s)); +} + #[test] fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index fa50c5ce6a..0452251a09 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -25,7 +25,8 @@ pub struct ViewInfo { /// View → Pixel Preview: while it is on, the tools use no Smart Guides. pub pixel_preview: bool, pub show_bbox: bool, - /// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi). + /// View → Snap to Pixel: drawing, moving and scaling land on whole pixels (points at 72 ppi), + /// each while its Pixel Snapping option (`view.snapToPixel.options`) is on. pub snap_to_pixel: bool, /// View → Snap to Point: picked points (a transform's reference point) land on anchors. pub snap_to_point: bool, @@ -368,6 +369,7 @@ impl Session { // Snap to Grid and Snap to Pixel are exclusive, as in Illustrator; left both on, the grid // wins instead of pixels quietly rounding every drag to whole points (#1816). snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid, + pixel_snap: self.prefs.pixel_snap, snap_to_point: view.snap_to_point, snap_tangent: self.prefs.snap_tangent, snap_perpendicular: self.prefs.snap_perpendicular, diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index fe56377d4e..e8b3ab0906 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -653,7 +653,7 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() { bytes }; let name = "Reload RGB"; - let dir = vectorcraft_testkit::temp_dir("reload-working-rgb"); + let dir = vectorcraft_testkit::TempDir::new("reload-working-rgb"); let load = |s: &mut Session, builtin: &str| { let path = dir.join(format!("{}.icc", builtin.len())); std::fs::write(&path, renamed(builtin, name)).unwrap(); @@ -734,11 +734,9 @@ impl Drop for ProofReset { } } -/// A folder of its own for the test `tag`, empty. -fn fresh_dir(tag: &str) -> std::path::PathBuf { - let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - dir +/// A folder of its own for the test `tag`, empty, removed when dropped. +fn fresh_dir(tag: &str) -> vectorcraft_testkit::TempDir { + vectorcraft_testkit::TempDir::new(&format!("color-settings-{tag}")) } /// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a @@ -785,7 +783,8 @@ fn color_settings_come_back_in_the_next_session() { let root = fresh_dir("color-settings"); let (profiles, file) = (root.join("Color Profiles"), root.join("Color Settings.json")); let loaded = "StoreRGB-6"; - let icc = vectorcraft_testkit::temp_dir("store-color-settings").join("loaded.icc"); + let icc_dir = vectorcraft_testkit::TempDir::new("store-color-settings"); + let icc = icc_dir.join("loaded.icc"); let _reset = ColorSettingsReset { profile: Some(loaded), paths: vec![root.clone(), icc.clone()] }; std::fs::write(&icc, icc_named(loaded)).unwrap(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 6577754de0..d88d99665f 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -12,10 +12,10 @@ use vectorcraft_testkit::invariants::{check_all, check_session, doc_json}; use vectorcraft_testkit::strategies::{command_param_keys, junk_params_for_command, junk_values, param_keys, safe_value_for_command}; /// Commands that replace/close the active document or touch the filesystem are fine to call, but -/// their file and folder params are redirected into a temp dir (`safe_value_for_command`, which -/// `junk_params_for_command` applies, by the keys the command catalog names as files and folders). -fn safe_path() -> String { - vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string() +/// their file and folder params are redirected into the test's temp dir `dir` (`safe_value_for_command`, +/// which `junk_params_for_command` applies, by the keys the command catalog names as files and folders). +fn safe_path(dir: &std::path::Path) -> String { + dir.join("out.bin").to_string_lossy().to_string() } /// `r`, after failing the case if the command panicked. The engine's guard reports a panic as @@ -219,7 +219,8 @@ fn junk_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { #[test] fn every_command_with_junk_params() { - let path = safe_path(); + let dir = vectorcraft_testkit::TempDir::new("sweep"); + let path = safe_path(&dir); let mut failures = vec![]; let mut calls = 0usize; for fx in Fixture::ALL { @@ -311,8 +312,8 @@ fn names_location(spec: Option<&vectorcraft_engine::CommandSpec>, key: &str) -> /// command alone in at least one case ([`junk_cases`]). #[test] fn sweep_cases_name_files_only_inside_the_temp_folder() { - let path = safe_path(); - let dir = vectorcraft_testkit::temp_dir("sweep"); + let dir = vectorcraft_testkit::TempDir::new("sweep"); + let path = safe_path(&dir); let mut unsent = vec![]; let mut outside = std::collections::BTreeSet::new(); for c in command_specs() { @@ -344,8 +345,8 @@ fn sweep_cases_name_files_only_inside_the_temp_folder() { /// `paths: [{ids, path}]`, each with a string in the temp folder, and a folder key a folder there. #[test] fn sweep_cases_send_the_file_keys_of_the_command_catalog() { - let path = safe_path(); - let dir = vectorcraft_testkit::temp_dir("sweep"); + let dir = vectorcraft_testkit::TempDir::new("sweep"); + let path = safe_path(&dir); let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap(); let inside = |v: &Value| v.as_str().is_some_and(|s| std::path::Path::new(s).starts_with(&dir)); for (id, key) in [ @@ -381,7 +382,8 @@ fn sweep_cases_send_the_file_keys_of_the_command_catalog() { /// document as it was, still applies after it. #[test] fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() { - let path = safe_path(); + let dir = vectorcraft_testkit::TempDir::new("sweep"); + let path = safe_path(&dir); let mut failures = vec![]; for fx in [Fixture::Single, Fixture::Multi] { for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) { @@ -1127,7 +1129,8 @@ fn serializable_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec Value { #[test] fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { - let root = vectorcraft_testkit::temp_dir("font-folder"); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(root.join("Downloads/Deep")).unwrap(); - let root = plain(std::fs::canonicalize(&root).unwrap()); + let dir = vectorcraft_testkit::TempDir::new("font-folder"); + std::fs::create_dir_all(dir.join("Downloads/Deep")).unwrap(); + let root = plain(std::fs::canonicalize(&*dir).unwrap()); let fonts = root.join("App Fonts"); vectorcraft_text::set_app_font_dir(fonts.clone()); assert!(vectorcraft_text::system_font_dirs().contains(&fonts)); @@ -85,5 +84,4 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { // Only files the last search found are copied. let r = s.execute("text.addFontFiles", &json!({"files": [root.join("other.ttf")]})); assert!(r.as_ref().is_err_and(|e| e.to_string().contains("the last text.findFontFiles search")), "{r:?}"); - let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 37f8c63acf..04e7094f9e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -6142,7 +6142,8 @@ fn findme_wanted() -> vectorcraft_text::WantedFonts { /// Whether reading `bytes` as a font file a search reads panics. fn search_reads(what: &str, bytes: &[u8]) -> Result, TestCaseError> { - let path = vectorcraft_testkit::temp_dir("font-search-fuzz").join(format!("{:?}.ttc", std::thread::current().id())); + let dir = vectorcraft_testkit::TempDir::new("font-search-fuzz"); + let path = dir.join("font.ttc"); std::fs::write(&path, bytes).unwrap(); catch_quiet(|| findme_wanted().provided_by(&path)).map_err(|e| TestCaseError::fail(format!("{what}: a search reading the file panicked: {e}"))) } diff --git a/crates/engine/tests/it/model_based.rs b/crates/engine/tests/it/model_based.rs index b11e3b0591..583dfda441 100644 --- a/crates/engine/tests/it/model_based.rs +++ b/crates/engine/tests/it/model_based.rs @@ -134,7 +134,8 @@ fn a_failed_batch_restores_the_file_the_history_and_a_drag_in_progress() { (doc_json(&st.doc), st.history.undo.len(), st.history.redo.len()) }; // Save As, then a failing step: the document keeps no file and stays modified. - let path = vectorcraft_testkit::temp_dir("batch-rollback").join("named.vectorcraft").to_string_lossy().to_string(); + let dir = vectorcraft_testkit::TempDir::new("batch-rollback"); + let path = dir.join("named.vectorcraft").to_string_lossy().to_string(); let save = json!({"command": "file.saveAs", "params": {"path": path}}); assert!(s.execute("command.batch", &json!({"commands": [rect(0), save, fail]})).is_err()); assert_eq!(state(&s), before); @@ -170,7 +171,7 @@ fn empty_batch_is_harmless() { #[test] fn save_open_via_commands_roundtrips() { let mut s = fixtures::rich_session(); - let dir = vectorcraft_testkit::temp_dir("engine-save"); + let dir = vectorcraft_testkit::TempDir::new("engine-save"); let path = dir.join("rich.vectorcraft"); exec(&mut s, "document.save", json!({"path": path.to_str().unwrap()})); let want = doc_json(&s.doc().unwrap().doc); diff --git a/crates/engine/tests/it/ruler_guide_nodes.rs b/crates/engine/tests/it/ruler_guide_nodes.rs index 950da2f065..1a88377585 100644 --- a/crates/engine/tests/it/ruler_guide_nodes.rs +++ b/crates/engine/tests/it/ruler_guide_nodes.rs @@ -103,10 +103,12 @@ fn ruler_guides_are_not_art_to_select_paint_or_inspect() { let layer_row = s.execute("document.inspect", &json!({"ids": [layer.0]})).unwrap(); let listed = layer_row.to_string(); assert!(guides.iter().all(|g| !listed.contains(&format!("\"id\":{}", g.0))), "{listed}"); - // Unlock All does not reach a locked guide. + // Unlock All unlocks a guide locked on its own and selects it as a guide, not as art. Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(guides[0]).unwrap().locked = true; s.execute("object.unlockAll", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.node(guides[0]).unwrap().locked); + let st = s.doc().unwrap(); + assert!(!st.doc.node(guides[0]).unwrap().locked); + assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![guides[0]], vec![])); } #[test] diff --git a/crates/engine/tests/removed_font.rs b/crates/engine/tests/removed_font.rs index ca5dc1f13a..da885b40b8 100644 --- a/crates/engine/tests/removed_font.rs +++ b/crates/engine/tests/removed_font.rs @@ -37,9 +37,7 @@ fn laid_out_in(s: &Session, id: u64) -> Vec { #[test] fn type_in_a_removed_font_falls_back_after_a_rescan() { - let root = vectorcraft_testkit::temp_dir("removed-font"); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); + let root = vectorcraft_testkit::TempDir::new("removed-font"); vectorcraft_text::set_user_font_dirs(vec![root.clone()]); let file = root.join("Gonefont.otf"); std::fs::write(&file, vectorcraft_testkit::fonts::renamed(FAMILY)).unwrap(); @@ -72,5 +70,4 @@ fn type_in_a_removed_font_falls_back_after_a_rescan() { assert_eq!(status(&mut s), ("exact".into(), FAMILY.into())); assert!(listed(&mut s)); assert_eq!(laid_out_in(&s, id), [FAMILY]); - let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index b46b962ce4..1321188f12 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -2634,6 +2634,34 @@ fn gradient_options_and_links_the_data_cant_hold_are_reported() { let back = read_back(&doc); let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") }; assert_eq!((color(&g.gradient.stops[0].color), g.gradient.stops[0].swatch.as_deref()), (color(&BRAND), None)); + // A gradient mixed around the hue wheel, either way, is written as a classic one of the stops + // on its curve, no more than the reader reads, and opens with the colors it had. + for method in [vectorcraft_color::gradient::GradientMethod::HueClockwise, vectorcraft_color::gradient::GradientMethod::HueCounterclockwise] { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut g = sunset(); + g.method = method; + let want = g.clone(); + let n = painted( + &mut doc, + shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(g, None, None)))], + ); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::GRADIENT_HUE], "{method:?}"); + let back = read_back(&doc); + let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") }; + assert!(g.gradient.method.is_classic(), "{method:?}"); + let written = g.gradient.stops.len(); + assert!((want.stops.len() + 1..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops"); + for k in 0..=100 { + let t = k as f32 / 100.0; + let ((a, ao), (b, bo)) = (want.sample(t), g.gradient.sample(t)); + let (a, b) = (a.to_rgb(), b.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03) && (ao - bo).abs() < 0.03, "{method:?} at {t}: {a:?} {ao} vs {b:?} {bo}"); + } + } } /// A pattern whose art reaches past its tile is written as its tiles cut to the shape: a definition @@ -7110,6 +7138,124 @@ fn every_effect_a_graphic_style_holds_is_read_back() { assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}"); } +/// An embedded 8 × 8 image of a color ramp, `Photo`, 80 pt square at (20, 10) in `doc`, at half +/// opacity, with `effects`. +fn warped_photo(doc: &mut Document, effects: Vec) -> Node { + let ramp = image::RgbImage::from_fn(8, 8, |x, y| image::Rgb([(x * 32) as u8, (y * 32) as u8, 160])); + let mut photo = image_node(doc, ImageBlob::png(png_of(ramp.into())), (8, 8), Affine::new([10.0, 0.0, 0.0, 10.0, 20.0, 10.0]), None); + photo.name = Some("Photo".into()); + photo.opacity = 0.5; + photo.appearance = Appearance { effects, ..Appearance::default() }.into(); + photo +} + +/// A 200 × 100 document holding `photo`, made by [`warped_photo`]. +fn photo_doc(photo: impl FnOnce(&mut Document) -> Node) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let n = photo(&mut doc); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc +} + +/// An embedded image with one Warp is written with an art style of that Warp, its bent pieces as its +/// drawn look and the image itself on `%_` lines: it reads back as the image, its pixels, placement, +/// name and opacity as they were, with its Warp live and its settings kept. +#[test] +fn an_image_with_a_warp_reads_back_with_its_warp_live() { + // GUARDS: an image with a Warp was written as its drawn pieces alone, and read back without its Warp. + for (id, params) in [ + ("warp.arc", serde_json::json!({"bend": 40.0})), + ("warp.flag", serde_json::json!({"bend": -30.0, "horizontal": 10.0, "vertical": 0.0, "orientation": "vertical"})), + ] { + let doc = photo_doc(|d| warped_photo(d, vec![fx(id, params.clone())])); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{id}: {:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{id}: {:#?} {:?}", lines(&back), out.warnings); + let (mut want, mut got) = (vec![], vec![]); + pictures(&doc, doc.layers[0].children().unwrap(), "", &mut want); + pictures(&back, art, "", &mut got); + assert_eq!(got, want, "{id}"); + let [warp] = art[0].appearance.effects.as_slice() else { panic!("{id}: {:?}", art[0].appearance) }; + assert_eq!(warp.id, id); + let (w, g) = (vectorcraft_effects::merged_params(id, ¶ms), vectorcraft_effects::merged_params(&warp.id, &warp.params)); + for key in ["bend", "horizontal", "vertical"] { + assert!((g[key].as_f64().unwrap() - w[key].as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {g} for {w}"); + } + assert_eq!(g["orientation"], w["orientation"], "{id}"); + } +} + +/// An image whose style's Warp bends it another way than its drawn look keeps that look, with the +/// note, and the file as written opens with the image. +#[test] +fn an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look() { + // GUARDS: an image read with its Warp live whatever its drawn look showed. + let doc = photo_doc(|d| warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))])); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let (from, to) = (b"0.4 /Real (DeformValue)".as_slice(), b"-0.4 /Real (DeformValue)".as_slice()); + let at = program.windows(from.len()).position(|w| w == from).expect("the warp's bend"); + let mut other = program.clone(); + other.splice(at..at + from.len(), to.iter().copied()); + let (back, warnings) = crate::ai_alone(&other).unwrap(); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{art:#?}"); + assert!(matches!(art[0].kind, NodeKind::Group { .. }), "the drawn look: {:?}", art[0].kind); + assert!(art[0].appearance.effects.is_empty(), "{:?}", art[0].appearance); + assert!(warnings.iter().any(|w| w.contains("drawn look")), "{warnings:?}"); + let (back, _) = crate::ai_alone(&program).unwrap(); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Image(_))); +} + +/// An image on `%_` lines is the copy of a styled object, as other objects there are: with a style +/// the file doesn't define, its drawn look stands for it, named as it is, and the image doesn't come +/// in beside the look. +#[test] +fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() { + // GUARDS: an image whose samples were on `%_` lines came in as a second, visible object. + let doc = photo_doc(|d| warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))])); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let (from, to) = (b"1 (Anon 1) XW".as_slice(), b"1 (Unknown) XW".as_slice()); + let at = program.windows(from.len()).position(|w| w == from).expect("the image's style"); + let mut unknown = program.clone(); + unknown.splice(at..at + from.len(), to.iter().copied()); + let (back, _) = crate::ai_alone(&unknown).unwrap(); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{:#?}", lines(&back)); + assert!(matches!(art[0].kind, NodeKind::Group { .. }), "the drawn look: {:?}", art[0].kind); + assert_eq!(art[0].name.as_deref(), Some("Photo")); +} + +/// A linked image, an image with an opacity mask, and an image with a Warp and another effect are +/// written as their drawn art, with no art style. +#[test] +fn other_warped_images_are_written_as_their_drawn_art() { + let arc = || fx("warp.arc", serde_json::json!({"bend": 40.0})); + let linked = photo_doc(|d| { + let mut n = warped_photo(d, vec![arc()]); + if let NodeKind::Image(im) = &mut n.kind { + im.link = Some(vectorcraft_doc::LinkInfo::new("/art/photo.png")); + } + n + }); + let masked = photo_doc(|d| { + let mut n = warped_photo(d, vec![arc()]); + let white = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::WHITE), Paint::None, 0.0)); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, false))); + n + }); + let stacked = photo_doc(|d| warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))])); + for (what, doc) in [("linked", linked), ("masked", masked), ("stacked", stacked)] { + assert!(!program_shown(&doc).contains("1 (Anon"), "{what}"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert!(art.iter().all(|n| n.appearance.effects.is_empty()), "{what}: {art:#?}"); + } +} + /// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. #[test] fn appearances_written_as_their_drawn_look() { diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index cd0455f10f..7db5d11896 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -36,7 +36,7 @@ pub(super) use images::PAYLOAD; #[cfg(test)] pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; #[cfg(test)] -pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; +pub(super) use paints::{GRADIENT_HUE, GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[cfg(test)] pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS}; #[cfg(test)] diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 0a7220faa5..f1b52dd91b 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -21,6 +21,7 @@ pub(in crate::ai_editing) const SWATCH_PLACEMENT: &str = "the placement of pattern swatches that move, turn or scale their pattern is left out of the editing data for other apps"; pub(in crate::ai_editing) const GRADIENT_MIX: &str = "gradients that mix their colors perceptually or dither are written as classic gradients in the editing data for other apps"; +pub(in crate::ai_editing) const GRADIENT_HUE: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256), in the editing data for other apps"; pub(in crate::ai_editing) const CONIC: &str = "conic gradients are written as linear gradients along their vector, with the same stops, in the editing data for other apps"; pub(in crate::ai_editing) const STOP_LINKS: &str = @@ -56,6 +57,19 @@ fn writable_gradient(g: &Gradient) -> bool { g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS } +/// `g` as the data holds its colors: one mixed around the hue wheel (the data's gradients mix +/// their stops' colors directly) as a classic gradient of the stops on its curve +/// ([`Gradient::as_classic_curve`]), no more than the `.ai` reader reads when it pads +/// ([`GRADIENT_HUE`]); others as they are. +fn hue_as_stops(g: &Gradient) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if !g.method.is_hue() { + return Cow::Borrowed(g); + } + let curve = g.as_classic_curve(); + Cow::Owned(if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() }) +} + /// Is a gradient of `kind` written as a radial one: a radial gradient, or a diamond (the ellipse /// through its square's corners, [`DIAMOND`])? fn written_radial(kind: GradientKind) -> bool { @@ -86,9 +100,11 @@ impl Writer<'_> { pub(in crate::ai_editing) fn reserve_swatch_gradients(&mut self) { let doc = self.doc; for sw in doc.swatches_iter() { - // As `swatch_paint` writes it: the stops of one that repeats or reflects repeated. + // As `swatch_paint` writes it: one mixed around the hue wheel as the stops on its curve, + // and the stops of one that repeats or reflects repeated. if let Paint::Gradient(g) = &sw.paint - && let gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS) + && let stops = hue_as_stops(&g.gradient) + && let gradient = stops.unrolled_within(crate::import::MAX_GRADIENT_STOPS) && writable_gradient(&gradient) && !self.gradients.iter().any(|d| d.name == sw.name) { @@ -149,6 +165,10 @@ impl Writer<'_> { // The data has no spread: a gradient that repeats or reflects is written with its stops // repeated over `area` (a conic one, written linear, around its turn). let mut g: GradientPaint = (**g).clone(); + if g.gradient.method.is_hue() { + self.warn(GRADIENT_HUE); + g.gradient = hue_as_stops(&g.gradient).into_owned(); + } if g.gradient.is_spread() { self.warn(SPREAD); match g.gradient.padded_within(&geom, area, crate::import::MAX_GRADIENT_STOPS) { @@ -557,13 +577,17 @@ impl Writer<'_> { /// be written. pub(super) fn swatch_paint(&mut self, s: &mut String, sw: &vectorcraft_color::swatch::Swatch) -> bool { // A swatch has no placement: one that repeats or reflects holds its stops repeated along - // its vector. - let unrolled = match &sw.paint { - Paint::Gradient(g) => Some(g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)), + // its vector, and one mixed around the hue wheel the stops on its curve. + let stops = match &sw.paint { + Paint::Gradient(g) => Some(hue_as_stops(&g.gradient)), _ => None, }; + let unrolled = stops.as_deref().map(|g| g.unrolled_within(crate::import::MAX_GRADIENT_STOPS)); match &sw.paint { Paint::Gradient(g) if unrolled.as_deref().is_some_and(writable_gradient) => { + if g.gradient.method.is_hue() { + self.warn(GRADIENT_HUE); + } if g.gradient.is_spread() { self.warn(SPREAD); } diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 29eb2bf910..fe3e68ce24 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -41,7 +41,8 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { /// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), /// by id: paths and compound paths with points and groups, with live effects, linked to a graphic -/// style or (paths and compound paths) with a brush on a stroke, without Opacity & Mask Define +/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp +/// ([`warped_image`]), without Opacity & Mask Define /// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. @@ -86,10 +87,22 @@ fn candidate(n: &Node) -> bool { NodeKind::Path { path, guide: false, clipping: false, .. } => has_points(path), NodeKind::Compound { .. } => has_points(&compound_path(n)), NodeKind::Group { children, clip: false } => !n.is_compound_shape() && children.iter().all(|c| plain_member(c)), + NodeKind::Image(_) => warped_image(n), _ => false, } } +/// Is `n` an embedded image without an opacity mask whose appearance is one Warp, with no fills or +/// strokes that paint and no effects on them: an image the reader reads with its Warp live +/// ([`crate::import::read_art_style`]'s `image_warp`)? +fn warped_image(n: &Node) -> bool { + let NodeKind::Image(im) = &n.kind else { return false }; + im.link.is_none() + && n.extras().mask.is_none() + && matches!(n.appearance.effects.as_slice(), [e] if e.id.starts_with("warp.")) + && n.appearance.items.iter().all(|i| i.paint().is_none() && i.effects().is_empty()) +} + /// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`] /// leaves out what paints nothing)? fn brushed(n: &Node) -> bool { @@ -666,7 +679,7 @@ impl Writer<'_> { return Some(name); } let brushed = brushed(orig); - if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) { + if (orig.appearance.effects.is_empty() && !brushed) || !(own_style_holds(&orig.appearance) || warped_image(orig)) { return None; } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); @@ -704,7 +717,8 @@ impl Writer<'_> { /// glows or feathers, the object's transparency /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or /// compound path's points painted with nothing, or with its gradient fill when its style has - /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + /// one; a group with its members; an image with its pixels), its name and opacity mask; then + /// `1 (name) XW`. /// The look's group and the object itself are counted first ([`Self::take_objects`]): without /// room for them, nothing is written, and the object counts neither as written with its graphic /// style nor as written with a brush on its stroke. @@ -712,7 +726,7 @@ impl Writer<'_> { let own = match &orig.kind { NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), - NodeKind::Group { .. } => 1, + NodeKind::Group { .. } | NodeKind::Image(_) => 1, _ => 0, }; if !self.take_objects(1 + own) { @@ -752,13 +766,14 @@ impl Writer<'_> { let saved = std::mem::take(&mut self.body); self.hidden += 1; let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); - // A path's objects were kept for it above; it counts them itself. + // A path's or an image's objects were kept for it above; it counts them itself. if !matches!(orig.kind, NodeKind::Group { .. }) { self.objects -= own; } match &orig.kind { NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), + NodeKind::Image(im) => self.image(&self.doc.shown_image(orig, im), xy), NodeKind::Group { children, .. } => { self.line("u"); for c in children { @@ -847,7 +862,8 @@ fn object_rule(n: &Node) -> FillRule { /// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without /// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those -/// are written only in the Graphic Styles panel's styles. +/// are written only in the Graphic Styles panel's styles, apart from a Warp on an image +/// ([`warped_image`]). fn own_style_holds(ap: &Appearance) -> bool { let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty(); let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p)); @@ -870,13 +886,16 @@ fn panel_reads(g: &GraphicStyle, text: &str) -> bool { /// Does the reader read object `n` written with style text `text` as it is: a path or compound /// path with a style it reads on any object, filled by the non-zero rule when the style fills it, /// or a group with no fills or strokes of its own, with effects that move the outline only where the -/// reader reads them ([`crate::import::style_moves_with`])? +/// reader reads them ([`crate::import::style_moves_with`]), or an image ([`warped_image`]) with a +/// style of one Warp? The reader takes such an image's Warp where it draws as its drawn look. fn object_reads(n: &Node, text: &str) -> bool { let section = known_style("x", text); let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; let group = matches!(n.kind, NodeKind::Group { .. }); - if !read.simple + let image = matches!(n.kind, NodeKind::Image(_)); + if (image && !(read.image_warp && warped_image(n))) + || (!image && !read.simple) || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) || !crate::import::style_moves_with(section.as_bytes(), "x", n) diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index a3de7560e3..4595061151 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -39,6 +39,7 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use envelope::MAX_CHECKED; pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN; pub(crate) use envelope::{GridPoint as EnvelopeGridPoint, PLUGIN as DEFORM_PLUGIN, outline as envelope_outline, upright as envelope_upright}; +pub(crate) use envelope::{MAX_ANY as ENVELOPE_MAX_ANY, MAX_SOLID as ENVELOPE_MAX_SOLID}; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub use obj::{NameEncoding, Names}; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 741ebfabdf..246121662d 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -73,9 +73,9 @@ const MOVED_SLACK: f64 = 0.5; const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17; /// More of the inked pixels than this differing, each with all its neighbours, and the envelope /// doesn't come out as drawn. -const MAX_SOLID: f64 = 0.03; +pub(crate) const MAX_SOLID: f64 = 0.03; /// More of the inked pixels than this differing at all, and the envelope doesn't come out as drawn. -const MAX_ANY: f64 = 0.1; +pub(crate) const MAX_ANY: f64 = 0.1; /// The whole number of entry `key`: `None` when it is missing, `Some(None)` when it isn't one. fn whole(o: &Obj, key: &str) -> Option> { @@ -648,6 +648,9 @@ pub(crate) enum TypeKept { Budget, /// Its live envelope doesn't draw as its drawn art. Differs, + /// A page that the layers, which don't draw as it, were compared with holds it against them + /// ([`WarpedType::open_alone`]). + Page, } /// What [`WarpedType::settle_envelopes`] did. @@ -657,9 +660,10 @@ pub(crate) struct TypedSettled { pub(crate) live: usize, /// Why each of the others kept its drawn art. pub(crate) kept: Vec, - /// Each live envelope's id, with what takes its place again when the layers with it don't draw - /// as the page: its hidden type, then its drawn art. - pub(crate) put_back: Vec<(NodeId, Vec)>, + /// Each live envelope's place among the envelopes of type (in the order they were read) and + /// id, with what takes its place again when the layers with it don't draw as the page: its + /// hidden type, then its drawn art. + pub(crate) put_back: Vec<(usize, NodeId, Vec)>, } /// The text objects read with a Warp and the envelopes of type, and what the import has left of the @@ -672,6 +676,9 @@ pub struct WarpedType { pub(super) checks: usize, pub(super) spent: Spent, pub(super) page: Option, + /// The envelopes of type that may open live in layers built without a page, by their place + /// among them ([`Self::open_alone`]): none when `None`. + pub(super) alone: Option>, } impl WarpedType { @@ -682,14 +689,33 @@ impl WarpedType { /// These, for layers `compared` with a page. Layers that aren't leave the envelopes of type /// their drawn art and their hidden type, with the note ([`TYPE_DRAWN`]) added to `notes`: a - /// live envelope opens only where the page chooses it. + /// live envelope opens only where the page chooses it. Unless [`Self::open_alone`] names some: + /// then they all wait, as with a page, and only those named may stay live. pub(crate) fn compared(mut self, compared: bool, notes: &mut Vec) -> Self { - if !compared && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) { + if !compared && self.alone.is_none() && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) { notes.push(TYPE_DRAWN.into()); } self } + /// Let the envelopes of type at `places` (among them, in the order they were read) open live in + /// layers built without a page, where they draw as their drawn art: a page these same layers, + /// read the same way, were compared with weighed them and didn't hold them against the layers' + /// difference from it. All the envelopes of type are settled as they were then, in the same + /// order and with the same checks, so each that opens live draws as it was weighed; then the + /// others get their drawn art back ([`Self::settle_envelopes`]). `count` is how many envelopes + /// of type that reading had: a reading with another number of them names none. + pub(crate) fn open_alone(&mut self, places: &[usize], count: usize) { + if self.envelopes.len() == count { + self.alone = Some(places.iter().copied().filter(|i| *i < count).collect()); + } + } + + /// How many envelopes of type wait for their type. + pub(crate) fn envelope_count(&self) -> usize { + self.envelopes.len() + } + /// Make each envelope of type live where it draws as its drawn art, its contents' text slots /// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in /// `doc` and the type the contents take in its place (the slot's visibility and lock are the @@ -713,16 +739,28 @@ impl WarpedType { doc.layers.iter().for_each(|l| collect_shown(l, &mut shown)); let envelopes = std::mem::take(&mut self.envelopes); let mut waiting = envelopes.len(); - for envelope in envelopes { + for (place, envelope) in envelopes.into_iter().enumerate() { waiting -= 1; match self.typed_live(doc, envelope, &type_of, &shown, waiting) { - Ok(put_back) => { + Ok((id, put_back)) => { settled.live += 1; - settled.put_back.push(put_back); + settled.put_back.push((place, id, put_back)); } Err(why) => settled.kept.push(why), } } + // Built without a page, the envelopes [`Self::open_alone`] doesn't name get their drawn art + // and hidden type back. One inside another that got its own back went with that art. + if let Some(free) = &self.alone { + let (stay, back): (Vec<_>, Vec<_>) = std::mem::take(&mut settled.put_back).into_iter().partition(|(place, ..)| free.contains(place)); + for (_, id, nodes) in back { + if doc.insert_beside(vec![(id, nodes)], true).is_ok() || doc.node(id).is_none() { + settled.live = settled.live.saturating_sub(1); + settled.kept.push(TypeKept::Page); + } + } + settled.put_back = stay; + } settled } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 3721ce7960..4b2936f56e 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -483,6 +483,9 @@ struct Reader<'a> { /// a global process colour, so these make swatches only for names no colour operator defines. stop_spots: Vec, raster: Option, + /// The samples of the image being read are on `%_` lines (`%_%%BeginData`): it is the copy of + /// a styled object there, as the other objects on those lines are. + image_hidden: bool, /// The file `XG` names for the image after it (`XF`), until an image is read. image_link: Option, /// The placed file being read (`%AI5_BeginPlace` … `~`). @@ -736,6 +739,7 @@ impl<'a> Reader<'a> { spots: Vec::new(), stop_spots: Vec::new(), raster: None, + image_hidden: false, image_link: None, place: None, foreign_data: None, @@ -846,7 +850,7 @@ impl<'a> Reader<'a> { self.op(w, hidden)?; } Tok::Comment(c) => self.comment(c, hidden)?, - Tok::Data(d) => self.image(d)?, + Tok::Data(d) => self.image(d, hidden)?, Tok::Preview(d) => { if let Some(p) = self.place.as_mut() { p.preview = preview_png(d); diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 3d859bf74e..7e0f91e993 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -370,15 +370,18 @@ impl<'a> Reader<'a> { // ---- images ---------------------------------------------------------------------------- - /// The samples after `XI`, its operands on the stack. - pub(super) fn image(&mut self, data: &[u8]) -> Result<(), String> { + /// The samples after `XI`, its operands on the stack; `hidden` when they are on `%_` lines. + pub(super) fn image(&mut self, data: &[u8], hidden: bool) -> Result<(), String> { let vals = self.operands(); // Operands that came before an `XI` on their own line. let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) { Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, _ => vals, }; - self.image_from(vals, data, false) + self.image_hidden = hidden; + let read = self.image_from(vals, data, false); + self.image_hidden = false; + read } /// The matrix and the numbers of an image's operands (`[a b c d tx ty] llx lly urx ury w h bits @@ -713,7 +716,8 @@ impl<'a> Reader<'a> { /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the - /// image, and `colorize` as the color that colorizes it. + /// image, and `colorize` as the color that colorizes it. It is on `%_` lines when its samples + /// are ([`Self::image`]). fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32), colorize: Option) -> Result<(), String> { // The matrix is an art-space matrix (y up) applied to the pixels with their rows running // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead @@ -735,7 +739,8 @@ impl<'a> Reader<'a> { let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; - self.add_image_colorized(im, mask, colorize, false) + // An image on `%_` lines is the copy of a styled object, as the other objects there are. + self.add_image_colorized(im, mask, colorize, self.image_hidden) } /// An image that isn't read is left out, with `warning`; in a symbol's art, the symbol isn't diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ca943e8817..0bcae74b32 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1367,6 +1367,7 @@ impl<'a> Reader<'a> { checks: self.envelope_checks, spent: self.envelope_spent, page, + alone: None, }, styles_read, text_patterns, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9f6a1dd6af..f79e7b9108 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -748,6 +748,7 @@ impl<'a> Reader<'a> { } let space = self.space(); self.note_brushes_kept(); + let page = self.page_in_doc(); let Some(f) = self.frames.last_mut() else { return }; let n = f.children.len(); let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { @@ -780,7 +781,7 @@ impl<'a> Reader<'a> { look }; // A style read here: the object, painted as it says. - if let Some(mut appearance) = self.styles.get(&style).and_then(|st| { + let appearance = self.styles.get(&style).and_then(|st| { styled( object, look, @@ -796,13 +797,32 @@ impl<'a> Reader<'a> { space, }, ) - }) { - // Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up). - let k = space.determinant().abs().sqrt(); - if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 { - appearance.scale_strokes(k); - appearance.scale_effects(k, vectorcraft_effects::scale_effect); + .map(|mut appearance| { + // Strokes and effects at the document's scale, as the art (a Large Canvas copy's is scaled up). + let k = space.determinant().abs().sqrt(); + if k.is_finite() && k > 0.0 && (k - 1.0).abs() > 1e-9 { + appearance.scale_strokes(k); + appearance.scale_effects(k, vectorcraft_effects::scale_effect); + } + appearance + }) + }); + // An image with a Warp is the object only where its live warp draws as its drawn look, and + // while the import has a check left for it; otherwise its drawn look stays. + let appearance = match appearance { + Some(ap) if matches!(object.kind, NodeKind::Image(_)) && ap.effects.iter().any(|e| e.id.starts_with("warp.")) => { + let alike = self.envelope_checks > 0 && { + self.envelope_checks -= 1; + let layer = self.doc.alloc_id(); + let shared = crate::import::ai::envelope::Shared { patterns: &self.doc.patterns, images: &self.doc.images }; + let shown = !hidden && object.visible && look.visible; + image_warp_alike(object, &ap, look, &self.hidden_ids, hidden, layer, &shared, page.filter(|_| shown), &mut self.envelope_spent) + }; + alike.then_some(ap) } + other => other, + }; + if let Some(appearance) = appearance { let Some(mut object) = f.children.pop() else { return }; let Some(look) = f.children.pop() else { return }; let left_out = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible); @@ -1116,6 +1136,41 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool { }) } +/// Is `object` an image without an opacity mask, and `st` one Warp on it with nothing else that +/// paints ([`crate::import::ai::style::ArtStyle::image_warp`])? Such an image is read with its Warp +/// live where that draws as its drawn look ([`image_warp_alike`]). +fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool { + matches!(object.kind, NodeKind::Image(_)) && object.extras().mask.is_none() && st.image_warp +} + +/// Does image `object` with `appearance` (its style's Warp) draw as `look`, its drawn look, compared +/// over all that the warp draws ([`crate::import::ai::envelope::warped_alike`]) on `layer`, a layer +/// id no other node has? Both are drawn shown, opaque and in the Normal mode. Unless the look is on +/// `%_` lines itself (`hidden`), the copies in it that are on `%_` lines (`hidden_ids`) are left out. +#[allow(clippy::too_many_arguments)] +fn image_warp_alike( + object: &Node, + appearance: &Appearance, + look: &Node, + hidden_ids: &HashSet, + hidden: bool, + layer: NodeId, + shared: &crate::import::ai::envelope::Shared<'_>, + page: Option, + spent: &mut crate::import::ai::envelope::Spent, +) -> bool { + let mut live = object.clone(); + live.appearance = appearance.clone().into(); + let mut plain = look.clone(); + if !hidden { + strip(&mut plain, hidden_ids); + } + for n in [&mut live, &mut plain] { + (n.visible, n.opacity, n.blend) = (true, 1.0, BlendMode::Normal); + } + crate::import::ai::envelope::warped_alike(&live, &plain, layer, shared, page, spent) +} + /// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. const PATTERN_KEY: &str = "\u{1}pattern "; @@ -1128,11 +1183,13 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p let slot = slot_of(object.name.as_deref()).is_some(); // Several fills and strokes, on a path or a compound path. let stacked = !slot && !st.simple && st.stacked && matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + // One Warp on an image, checked against its drawn look by the caller ([`image_warp_alike`]). + let image_warp = !st.simple && image_warps(object, st); // A symbol instance draws its symbol's art: it keeps its drawn look. if !st.plain || matches!(object.kind, NodeKind::SymbolInstance { .. }) // A look with no outline in it to keep takes a variable-width stroke as the object's own. - || (!slot && !st.simple && !stacked && (!st.uneven || draws(look, &HashSet::new()))) + || (!slot && !st.simple && !stacked && !image_warp && (!st.uneven || draws(look, &HashSet::new()))) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) || (st.pathfinder && (slot || !st.items.is_empty() || object.is_compound_shape() || !matches!(object.kind, NodeKind::Group { clip: false, .. }))) @@ -1266,6 +1323,9 @@ pub(crate) struct StyleRead { pub appearance: Appearance, /// An object other than type is read with it ([`crate::import::ai::style::ArtStyle::simple`]). pub simple: bool, + /// An image without an opacity mask is read with it where its live warp draws as its drawn + /// look ([`crate::import::ai::style::ArtStyle::image_warp`]). + pub image_warp: bool, /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. pub pathfinder: bool, /// The opacity, blending mode, isolation and knockout its blend filter gives. @@ -1310,7 +1370,7 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P s.brush = Some(brush); } } - Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) + Some(StyleRead { appearance, simple: st.simple, image_warp: st.image_warp, pathfinder: st.pathfinder, transparency: st.transparency }) } /// Is art style `name` of `section` read on `object` as far as an effect that moves the outline @@ -1519,6 +1579,79 @@ mod tests { assert!(styled(&Node::new(NodeId(4), NodeKind::Image(Box::new(image))), &look, st, &paints).is_none()); } + /// The style `Fx`: `effects` (each a filter's name and its dictionary's keys) on the whole + /// object, over a fill and a stroke that `paints` turns on (`0 1`: the fill paints blue). + fn effects_style(effects: &[(&str, &str)], paints: &str) -> crate::import::ai::style::ArtStyle { + let filters: String = effects + .iter() + .map(|(name, keys)| { + format!("/BasicFilter :\n({name}) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n") + }) + .collect(); + let text = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Fx) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + {filters}/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 1 0 0 0 Xy\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n{paints} Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ); + crate::import::ai::style::art_styles(text.as_bytes()).remove("Fx").unwrap_or_default() + } + + /// An image takes a style of one Warp, under fills and strokes that paint nothing, and no + /// other style with a geometry effect: those keep its drawn look. A path, and an image with an + /// opacity mask, keep their drawn look with the Warp too. + #[test] + fn an_image_takes_a_style_of_one_warp_alone() { + // GUARDS: an image with a Warp in its art style came in as its drawn look. + let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files + let (gradients, patterns, names, v7_patterns, bending) = + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + let paints = StylePaints { + gradients: &gradients, + patterns: &patterns, + names: &names, + v7_patterns: &v7_patterns, + brushes: &[], + kept: &[], + bending: &bending, + space: Affine::IDENTITY, + }; + let look = Node::group(NodeId(1), vec![square(2, Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))]); + let image = + ImageObject { key: "a".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let image = Node::new(NodeId(4), NodeKind::Image(Box::new(image))); + let st = effects_style(&[warp], "0 0"); + assert!(st.image_warp && !st.simple, "{st:?}"); + let ap = styled(&image, &look, &st, &paints).expect("an image takes one Warp"); + let ids: Vec<&str> = ap.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["warp.arc"]); + assert!(ap.items.iter().all(|i| i.paint().is_none()), "{ap:?}"); + // Not on a path, nor on an image with an opacity mask. + assert!(styled(&square(3, Paint::None), &look, &st, &paints).is_none()); + let mut masked = image.clone(); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(7), vec![]), true))); + assert!(styled(&masked, &look, &st, &paints).is_none()); + // A Warp over a fill that paints, a Warp with another effect, and the other geometry effects. + let still = ("Adobe Punk and Bloat", "0 /Real (d_factor) ,\n"); + let others: [(&[(&str, &str)], &str); 8] = [ + (&[warp], "0 1"), + (&[warp, still], "0 0"), + (&[still, warp], "0 0"), + (&[("Adobe Twirl", "45 /Real (angle) ,\n")], "0 0"), + (&[("Adobe Free Distort", "")], "0 0"), + (&[("Adobe Punk and Bloat", "-50 /Real (d_factor) ,\n")], "0 0"), + (&[("Adobe Zigzag", "3 /Real (amount) ,\n1 /Real (absoluteness) ,\n")], "0 0"), + (&[("Adobe Transform", "5 /Real (moveH_Pts) ,\n0.5 /Real (rotate_Radians) ,\n0 /Bool (transformPatterns) ,\n")], "0 0"), + ]; + for (effects, painted) in others { + let st = effects_style(effects, painted); + assert!(!st.image_warp, "{effects:?} {painted}"); + assert!(styled(&image, &look, &st, &paints).is_none(), "{effects:?} {painted}"); + } + } + #[test] fn data_stops_at_its_end_or_a_marker_of_the_section() { let section = Some(("BeginTextDocument", "EndTextDocument")); diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 482c172386..d27a297925 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -193,6 +193,10 @@ pub(super) struct ArtStyle { /// the effects any object takes that don't move the outline: what a path or compound path is /// read with when each gradient is placed as its drawn look has it (`read::ops`' `styled`). pub stacked: bool, + /// It is one Warp on the whole object, under fills and strokes that paint nothing and have no + /// effects of their own: what an image is read with when its live warp draws as its drawn look + /// (`read::ops`' `styled`). + pub image_warp: bool, /// Its fills and strokes, bottom first. pub items: Vec, /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. @@ -1778,6 +1782,10 @@ fn style(filters: &[Filter]) -> ArtStyle { && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Gradient { .. } | StyleItem::Stroke { brush: None, .. })) && st.looks.iter().all(|l| l.effects.is_empty()) && st.effects.iter().all(any_object_takes); + st.image_warp = st.plain + && matches!(st.effects.as_slice(), [e] if e.id.starts_with("warp.")) + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(p) | StyleItem::Stroke { paint: p, .. } if p.is_none())) + && st.looks.iter().all(|l| l.effects.is_empty()); st } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9b86f38350..f2a0a41ca7 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8280,7 +8280,7 @@ fn warped_type_on_the_page_spends_what_is_left_of_the_budget() { let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None }; let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0)); let settle = |w: &super::envelope::WarpedLook, spent| { - let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page }; + let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page, alone: None }; warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![]) }; let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 }; diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 8adcfccef0..1b212194a1 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1,5 +1,6 @@ use super::compare::{ - MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PageView, compare, comparison_samples, difference, pixels_compared, + MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PAST_THE_PAGE, PageView, compare, comparison_samples, difference, + pixels_compared, }; use super::plan::{made_only, make_slots, plan, readable_all}; use super::slots::{ @@ -230,6 +231,24 @@ pub(super) fn build_from( outlined: bool, trust: bool, unit: f64, +) -> Result<(Document, Vec), String> { + build_weighing(structure, data, visible, page, outlined, trust, unit, &mut None) +} + +/// [`build_from`]. When the layers with their live envelopes don't draw as the page, and don't with +/// the drawn art of every live envelope put back either, `free` is set to the envelopes of type +/// (by their place among them, as read) that opened live and that the page doesn't hold against +/// them ([`held_back`]), whether or not the layers are then used, and however. +#[allow(clippy::too_many_arguments)] +pub(super) fn build_weighing( + structure: Structure, + data: &[u8], + visible: Option<&Document>, + page: Page, + outlined: bool, + trust: bool, + unit: f64, + free: &mut Option>, ) -> Result<(Document, Vec), String> { let Structure { mut doc, @@ -468,12 +487,14 @@ pub(super) fn build_from( let warped_type = warped_type.compared(frame.is_some(), &mut notes); let bound = [&bound_text, &bound_visible]; let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, bound, &mut restyled, &mut notes)?; + // Each live envelope of type's place among the envelopes of type, by its id. + let places: Vec<(NodeId, usize)> = typed.iter().map(|(place, id, _)| (*id, *place)).collect(); // The live envelopes and what takes the place of each again, when the layers with them don't // draw as the page, with the note that says so. let envelopes_drawn: Vec<(NodeId, Vec, &str)> = envelopes_drawn .into_iter() .map(|(id, art)| (id, vec![art], ai::ENVELOPE_DRAWN)) - .chain(typed.into_iter().map(|(id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN))) + .chain(typed.into_iter().map(|(_, id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN))) .collect(); if empty > 0 { notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); @@ -525,20 +546,21 @@ pub(super) fn build_from( notes.extend(unseen_note(unseen).into_iter().chain(note)); } other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow) { - Ok(more) => notes.extend(more), + Ok((more, held)) => { + *free = held.map(|held| free_places(&places, &held)); + notes.extend(more); + } // The objects that draw differently from the page come in as its art, from // the placement that draws closer to it. The layers don't draw as the page: - // envelopes of type keep their drawn art and their type, hidden. + // the envelopes of type it holds against them keep their drawn art and their + // type, hidden. Where no placement's differing objects can then come in as the + // page's art (an object that holds type isn't replaced), every envelope of type + // keeps its drawn art, and that is tried again. Err(why) => { - let typed = type_drawn(&mut doc, &envelopes_drawn); - let other = other.map(|(_, mut other, unseen)| { - type_drawn(&mut other, &envelopes_drawn); - (other, unseen) - }); - let placements = closer_first(&frame, &mut view, (doc, unseen), other, reflow); - let Some((kept, unseen, kept_notes)) = placements.into_iter().find_map(|(base, unseen)| { - keep_differing(&base, &frame, &mut view, &threaded_frames, &shown, reflow).map(|(kept, n)| (kept, unseen, n)) - }) else { + let other = other.map(|(_, other, unseen)| (other, unseen)); + let layers = Placements { doc: &doc, unseen, other: other.as_ref(), drawn: &envelopes_drawn, places: &places }; + let found = keep_differing_typed(&frame, &mut view, &layers, &threaded_frames, &shown, reflow, free); + let Some((kept, unseen, kept_notes, typed)) = found else { return Err(why); }; doc = kept; @@ -693,8 +715,9 @@ fn bound_slots( /// anchors. Before that, the envelopes of type open live where they draw as their drawn art, with /// the type of their text slots (made type, one text object for the slot, not threaded, bound to /// no text variable and no visibility variable, `bound`'s second set, and no wrap object); the -/// others keep their drawn art, with a note. Returns each live envelope of type's id with what -/// takes its place again (its hidden type, then its drawn art). +/// others keep their drawn art, with a note. Returns each live envelope of type's place among the +/// envelopes of type (in the order they were read) and id, with what takes its place again (its +/// hidden type, then its drawn art). fn settle_warped_type( mut warped: ai::WarpedType, doc: &mut Document, @@ -703,7 +726,7 @@ fn settle_warped_type( bound: [&BTreeSet; 2], restyled: &mut Vec, notes: &mut Vec, -) -> Result)>, String> { +) -> Result)>, String> { let [bound_text, bound_visible] = bound; let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) { Some(&[one]) => Some(one), @@ -1152,15 +1175,15 @@ const MAX_DIFFERING: usize = 64; const MAX_PATCH_PAIRS: usize = 4_000_000; /// The layers as read and at the placed files' other placement (each with its count of text -/// objects the page doesn't draw), the one that draws closer to the page first. `reflow` as in -/// [`PageView::difference`]. -fn closer_first( +/// objects the page doesn't draw, and what goes with it), the one that draws closer to the page +/// first. `reflow` as in [`PageView::difference`]. +fn closer_first( frame: &Frame<'_>, view: &mut PageView, - doc: (Document, usize), - other: Option<(Document, usize)>, + doc: (Document, usize, T), + other: Option<(Document, usize, T)>, reflow: bool, -) -> Vec<(Document, usize)> { +) -> Vec<(Document, usize, T)> { match other { Some(other) if view.difference_on_every_page(&other.0, frame.rect, reflow).any < view.difference_on_every_page(&doc.0, frame.rect, reflow).any => @@ -1278,6 +1301,73 @@ fn keep_differing( Some((kept, note.into_iter().chain([format!("{DREW_DIFFERENTLY}: {}", layers.join(", "))]).collect())) } +/// The places (among the envelopes of type, as read) of the live envelopes of type in `places` +/// (each with its id) that `held` doesn't list. +fn free_places(places: &[(NodeId, usize)], held: &BTreeSet) -> Vec { + places.iter().filter(|(id, _)| !held.contains(id)).map(|(_, place)| *place).collect() +} + +/// The layers of [`keep_differing_typed`]: `doc`, `unseen` text objects of it taken out; the placed +/// files' other placement of it, when it has one, with how many of its text objects were; its live +/// envelopes, as [`drawn_or_trusted`] takes them; and the place of each live envelope of type +/// among the envelopes of type, by its id. +struct Placements<'a> { + doc: &'a Document, + unseen: usize, + other: Option<&'a (Document, usize)>, + drawn: &'a [(NodeId, Vec, &'a str)], + places: &'a [(NodeId, usize)], +} + +/// [`keep_differing`] of the placement of `layers` that draws closer to the page, the envelopes of +/// type the page holds against that placement ([`held_back`], each placement weighed on its own: the +/// other one draws placed files elsewhere, under or over the envelopes) having their drawn art and +/// hidden type back; and where none of the placements' differing objects can then come in as the +/// page's art, every envelope of type: the document, how many of its text objects were taken out, +/// its notes, and whether an envelope of type got its drawn art back. A placement that draws as the +/// page with only the envelopes the page holds put back is used as it is, as one with all the live +/// envelopes' drawn art put back would have been ([`drawn_or_trusted`]): nothing in it differs to +/// come in as the page's art. `free` is set to the places of those the page doesn't hold against +/// the layers as read. +#[allow(clippy::too_many_arguments)] +fn keep_differing_typed( + frame: &Frame<'_>, + view: &mut PageView, + layers: &Placements<'_>, + threaded_frames: &BTreeSet, + shown: &[Vec>], + reflow: bool, + free: &mut Option>, +) -> Option<(Document, usize, Vec, bool)> { + let held = held_back(frame, view, layers.doc, layers.drawn, reflow); + *free = Some(free_places(layers.places, &held)); + let held_other = layers.other.map(|(other, _)| held_back(frame, view, other, layers.drawn, reflow)); + let all: BTreeSet = layers.places.iter().map(|(id, _)| *id).collect(); + let fewer = held.len() < all.len() || held_other.as_ref().is_some_and(|h| h.len() < all.len()); + let mut tries = vec![]; + if fewer { + tries.push((held, held_other)); + } + tries.push((all.clone(), Some(all.clone()))); + tries.into_iter().find_map(|(held, held_other)| { + // Each placement with what it put back: whether an envelope of type, and whether only some. + let mut base = layers.doc.clone(); + let typed = (type_drawn(&mut base, layers.drawn, &held), held.len() < all.len()); + let other = layers.other.map(|(other, unseen)| { + let (mut other, held) = (other.clone(), held_other.as_ref().unwrap_or(&held)); + let typed = (type_drawn(&mut other, layers.drawn, held), held.len() < all.len()); + (other, *unseen, typed) + }); + let placements = closer_first(frame, view, (base, layers.unseen, typed), other, reflow); + placements.into_iter().find_map(|(base, unseen, (typed, some))| { + if some && let Ok(notes) = compare(frame, view, &base, reflow) { + return Some((base, unseen, notes, typed)); + } + keep_differing(&base, frame, view, threaded_frames, shown, reflow).map(|(kept, notes)| (kept, unseen, notes, typed)) + }) + }) +} + /// Copy page drawing IDs, including mask art outside the ordinary child tree. pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node { let mut copy = doc.reid(source); @@ -1338,9 +1428,9 @@ fn slot_content( /// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the /// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took /// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that; -/// else, with `trust`, the notes for `doc` as it is but for its envelopes of type, which keep their -/// drawn art ([`type_drawn`]). An error, `why`, otherwise. The envelope note is added unless -/// `notes` has it. +/// else, with `trust`, the notes for `doc` as it is but for the envelopes of type the page holds +/// against it ([`held_back`]), which keep their drawn art ([`type_drawn`]), with those envelopes. +/// An error, `why`, otherwise. The envelope note is added unless `notes` has it. #[allow(clippy::too_many_arguments)] fn drawn_or_trusted( frame: &Frame<'_>, @@ -1352,7 +1442,7 @@ fn drawn_or_trusted( trust: bool, why: String, reflow: bool, -) -> Result, String> { +) -> Result<(Vec, Option>), String> { let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); let mut put_back = doc.clone(); // Each envelope still there, in place of the art it took the place of. @@ -1379,26 +1469,143 @@ fn drawn_or_trusted( envelope.push(say.to_string()); } } - return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect()); + return Ok((envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect(), None)); } } if trust { - let typed = type_drawn(doc, drawn).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n)); + let held = held_back(frame, view, doc, drawn, reflow); + let typed = type_drawn(doc, drawn, &held).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n)); let trusted = format!("{why}: they come from its editing data all the same"); - return Ok(typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect()); + return Ok((typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect(), Some(held))); } Err(why) } +/// The live envelopes of type in `drawn` (as [`drawn_or_trusted`] takes them) that the page holds +/// against the layers `doc`, which don't draw as it even with every live envelope's drawn art put +/// back: by their ids, those where the layers don't draw as the page. The envelope and its drawn art +/// are drawn together on a layer of their own, without the rest of the layers, on the page check's +/// drawing of each page they lie on: in the pixels they ink there, the layers with the envelope may +/// differ from the page in no more than the envelope's own check allows against its drawn art (at +/// all, and with all their neighbors, away from type: [`compare`]); and in the order they were +/// read, the envelopes that stay live may differ there in no more of each page together than half +/// of what the page check allows (a budget of the same size as the one their own checks share, kept +/// apart from it). Art that differs elsewhere on the page doesn't hold them. One the layers don't +/// draw where it is and as itself ([`drawn_in_place`]) is held, wherever it lies: drawn on its own, +/// it would be weighed elsewhere than it draws, or on pixels a clip or mask hides, and what holds it +/// can draw it onto a page from off it. Another envelope on no page stays live, as the page check +/// doesn't see it, unless the page holds all the art that prints ([`Frame::whole`]), where art past +/// it is compared too: there one reaching past the page is held. Past [`MAX_WEIGHED`] of them on +/// the pages, or where the page check can't weigh one, they are all held. +fn held_back(frame: &Frame<'_>, view: &mut PageView, doc: &Document, drawn: &[(NodeId, Vec, &str)], reflow: bool) -> BTreeSet { + let typed: Vec<(NodeId, &Node, &Node)> = drawn + .iter() + .filter(|(_, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN) + .filter_map(|(id, art, _)| Some((*id, doc.node(*id)?, art.last()?))) + .collect(); + let all: BTreeSet = typed.iter().map(|(id, ..)| *id).collect(); + if all.is_empty() { + return all; + } + let pages = view.regions(frame.rect); + let past = |b: Rect| frame.whole && !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(b); + let mut held = BTreeSet::new(); + let mut in_place = std::collections::HashMap::new(); + drawn_in_place(&doc.layers, &all, true, &mut in_place); + // Those the page check sees, by the boxes of the envelope and its drawn art. + let mut seen = vec![]; + for (id, live, art) in typed { + if !in_place.get(&id).copied().unwrap_or(false) { + held.insert(id); + continue; + } + match vectorcraft_geom::union_opt(live.visual_bounds(), art.visual_bounds()) { + Some(b) if past(b) => { + held.insert(id); + } + Some(b) if pages_touched(b, &pages, 1.0).is_empty() => {} + _ => seen.push((id, live, art)), + } + } + if seen.len() > MAX_WEIGHED { + return all; + } + // The pixels of each page where the envelopes that stay live differ from it: at all, and with + // all their neighbors. + let mut spent = vec![(0usize, 0usize); pages.len()]; + for (id, live, art) in seen { + // The envelope and its drawn art, shown, on a layer of their own in `doc`. + let mut nodes: Vec> = [live, art].into_iter().cloned().map(Arc::new).collect(); + for n in &mut nodes { + let n = Arc::make_mut(n); + (n.visible, n.opacity) = (true, 1.0); + } + let mut one = doc.clone(); + let mut layer = Node::layer(NodeId(0), "", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(children) = layer.children_mut() { + *children = nodes; + } + one.layers = vec![Arc::new(layer)]; + let bounds = vectorcraft_render::encode::art_bounds(&one); + if bounds.is_some_and(past) { + held.insert(id); + continue; + } + let on = bounds.map_or(vec![], |b| pages_touched(b, &pages, 1.0)); + let mut added = spent.clone(); + let mut fits = true; + for (i, ink) in view.inked(&one, &on) { + let Some((any, solid)) = view.differing_in(doc, i, &ink, reflow) else { return all }; + let inked = ink.iter().filter(|p| **p).count() as f64; + let pixels = ink.len().max(1) as f64; + let Some((a, s)) = added.get_mut(i) else { return all }; + *a += any; + *s += solid; + fits &= any as f64 <= inked * ai::ENVELOPE_MAX_ANY + && solid as f64 <= inked * ai::ENVELOPE_MAX_SOLID + && *a as f64 <= pixels * MAX_DIFFERENCE / 2.0 + && *s as f64 <= pixels * MAX_SOLID_DIFFERENCE / 2.0; + } + if fits { + spent = added; + } else { + held.insert(id); + } + } + held +} + +/// Which of the objects `ids` among `nodes` (or what they hold) are drawn where they are and as +/// themselves (`plain`: so far, above `nodes`), into `out` by id: under layers and groups that +/// neither clip them, mask them nor have an appearance of their own (whose effects can move or copy +/// them), and in no envelope, repeat, blend or other object that draws what it holds elsewhere or +/// more than once. +fn drawn_in_place(nodes: &[Arc], ids: &BTreeSet, plain: bool, out: &mut std::collections::HashMap) { + for n in nodes { + if ids.contains(&n.id) { + out.insert(n.id, plain); + } + let Some(children) = n.children() else { continue }; + let keeps = match n.kind { + NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => { + !clip && n.appearance == vectorcraft_doc::Appearance::default() && n.extras().mask.is_none() + } + _ => false, + }; + drawn_in_place(children, ids, plain && keeps, out); + } +} + /// Put each live envelope of type in `drawn` (as [`drawn_or_trusted`] takes them) that `doc` still -/// has back as its hidden type and its drawn art: whether one was. An envelope of type opens live -/// only where the layers with it draw as the page, so where they don't, though they are used all -/// the same (the editing data trusted, or the objects that differ come in as the page's art), it -/// keeps its drawn art. -fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec, &str)]) -> bool { +/// has and `held` lists back as its hidden type and its drawn art: whether one was. An envelope of +/// type opens live only where the page doesn't hold it against the layers ([`held_back`]), so where +/// they don't draw as the page though they are used all the same (the editing data trusted, or the +/// objects that differ come in as the page's art), one the page holds against them keeps its drawn +/// art. +fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec, &str)], held: &BTreeSet) -> bool { let typed: Vec<(NodeId, Vec)> = drawn .iter() - .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && doc.node(*id).is_some()) + .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && held.contains(id) && doc.node(*id).is_some()) .map(|(id, art, _)| (*id, art.clone())) .collect(); if typed.is_empty() || doc.insert_beside(typed, true).is_err() { @@ -2562,4 +2769,359 @@ mod candidate_tests { replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap(); assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id)); } + + /// A rectangle on `rect` filled with `color`, with an id of `doc`'s. + fn filled_rect(doc: &mut Document, rect: Rect, color: vectorcraft_color::Color) -> Node { + let paint = vectorcraft_color::Paint::solid(color); + Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(rect), + vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0), + ) + } + + /// A live envelope as [`drawn_or_trusted`] takes it: its id, what takes its place again, its note. + type Envelope = (NodeId, Vec, &'static str); + + /// The layers `doc` with a black rectangle on each of `lives` standing in for a live envelope of + /// type, its drawn art a black rectangle on the matching one of `arts`: the layers, and the + /// envelopes as [`drawn_or_trusted`] takes them. + fn stand_ins(lives: &[Rect], arts: &[Rect]) -> (Document, Vec) { + let mut doc = Document::new(100.0, 100.0); + let layer = doc.layers[0].id; + let mut drawn = vec![]; + for (live, art) in lives.iter().zip(arts) { + let n = filled_rect(&mut doc, *live, vectorcraft_color::Color::BLACK); + let id = n.id; + doc.insert(Some(layer), 0, n).unwrap(); + let art = filled_rect(&mut doc, *art, vectorcraft_color::Color::BLACK); + drawn.push((id, vec![art], ai::ENVELOPE_TYPE_DRAWN)); + } + (doc, drawn) + } + + /// The page holds an envelope of type against layers that don't draw as it only where the + /// layers with the envelope differ from the page where it or its drawn art inks: not where the + /// page draws other art that the layers don't have. + #[test] + fn the_page_holds_back_only_envelopes_of_type_where_it_differs_from_them() { + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let mut page = Document::new(100.0, 100.0); + let layer = page.layers[0].id; + // The first two envelopes' drawn art, and a square the layers don't have. + for r in [Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(10.0, 60.0, 30.0, 80.0)] { + let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK); + page.insert(Some(layer), 0, n).unwrap(); + } + // Live where its drawn art is; moved off its drawn art; and where the page draws nothing. + let (doc, drawn) = stand_ins( + &[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(66.0, 10.0, 86.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)], + &[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)], + ); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &doc, false).is_err()); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0, drawn[2].0])); + // Only envelopes of type are weighed. + let other: Vec<_> = drawn.iter().map(|(id, art, _)| (*id, art.clone(), ai::ENVELOPE_DRAWN)).collect(); + assert!(held_back(&frame, &mut view, &doc, &other, false).is_empty()); + } + + /// The envelopes of type that stay live may together differ from the page where they ink in no + /// more than half of what the page check allows: the next one past that is held back, though + /// on its own it would stay. + #[test] + fn envelopes_of_type_stay_live_within_half_of_what_the_page_check_allows() { + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let halves = [Rect::new(0.0, 0.0, 100.0, 25.0), Rect::new(0.0, 25.0, 100.0, 50.0)]; + let mut page = Document::new(100.0, 100.0); + let layer = page.layers[0].id; + for r in halves { + let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK); + page.insert(Some(layer), 0, n).unwrap(); + } + // White lines two pixels high over each, 6 points apart: 8% of what each inks differs, 2% of + // the page, and none of it with all its neighbors. + for (i, y) in [3.0, 9.0, 15.0, 21.0, 28.0, 34.0, 40.0, 46.0].into_iter().enumerate() { + let n = filled_rect(&mut page, Rect::new(0.0, y, 100.0, y + 0.5), vectorcraft_color::Color::WHITE); + page.insert(Some(layer), halves.len() + i, n).unwrap(); + } + let (doc, drawn) = stand_ins(&halves, &halves); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }; + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0])); + assert!(held_back(&frame, &mut view, &doc, &drawn[1..], false).is_empty()); + } + + /// A page of 100 points with a black rectangle on each of `rects`, and its frame (`whole`: the + /// page holds all the art that prints). + fn black_page(rects: &[Rect]) -> Document { + let mut page = Document::new(100.0, 100.0); + let layer = page.layers[0].id; + for r in rects { + let n = filled_rect(&mut page, *r, vectorcraft_color::Color::BLACK); + page.insert(Some(layer), 0, n).unwrap(); + } + page + } + + /// The frame of `page`, its 100-point square (`whole`: the page holds all the art that prints). + fn frame_of(page: &Document, whole: bool) -> Frame<'_> { + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + Frame { page, page_rect: rect, rect, whole, pages: vec![(rect, Affine::IDENTITY)], more: vec![] } + } + + /// An envelope of type is held back where the layers with it differ from the page in more of + /// the pixels it and its drawn art ink than its own check allows against its drawn art, though + /// that is far less of the page than the page check allows. + #[test] + fn an_envelope_of_type_differing_in_more_of_its_pixels_than_its_check_allows_is_held_back() { + // Half a point off its drawn art, which the page draws: 22% of the pixels they ink differ, + // a tenth of a percent of the page, and none with all their neighbors. + let page = black_page(&[Rect::new(30.5, 10.0, 34.5, 20.0)]); + let (doc, drawn) = stand_ins(&[Rect::new(30.0, 10.0, 34.0, 20.0)], &[Rect::new(30.5, 10.0, 34.5, 20.0)]); + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[0].0])); + } + + /// An envelope of type that the layers don't draw where it is and as itself is held back though + /// it draws as the page, wherever it lies: one that a clip group or a clipping sublayer clips, an + /// opacity mask masks, or that a group with an appearance of its own, another envelope, a repeat + /// or a blend holds. Drawn on its own, it would be weighed on pixels a clip hides, or elsewhere + /// than it draws, and what holds it can draw it onto the page from off it. One in a plain group + /// is weighed, and one off the page in a plain group stays live. + #[test] + fn envelopes_of_type_the_layers_dont_draw_in_place_are_held_back() { + let at = |i: usize| { + let (x, y) = if i < 4 { (5.0 + 20.0 * i as f64, 5.0) } else { (5.0 + 20.0 * (i - 4) as f64, 30.0) }; + Rect::new(x, y, x + 15.0, y + 15.0) + }; + let off = [Rect::new(120.0, 5.0, 135.0, 20.0), Rect::new(140.0, 5.0, 155.0, 20.0)]; + let page = black_page(&(0..8).map(at).collect::>()); + let mut doc = Document::new(100.0, 100.0); + let layer = doc.layers[0].id; + let mut drawn = vec![]; + let mut stand_in = |doc: &mut Document, r: Rect| { + let n = filled_rect(doc, r, vectorcraft_color::Color::BLACK); + drawn.push((n.id, vec![filled_rect(doc, r, vectorcraft_color::Color::BLACK)], ai::ENVELOPE_TYPE_DRAWN)); + Arc::new(n) + }; + let group = |doc: &mut Document, children: Vec>, clip: bool| Node::new(doc.alloc_id(), NodeKind::Group { children, clip }); + let fill = vectorcraft_doc::Appearance { + items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid( + vectorcraft_color::Color::BLACK, + )))], + ..Default::default() + }; + let mut objects = vec![]; + // 0: in a plain group. + let n = stand_in(&mut doc, at(0)); + objects.push(group(&mut doc, vec![n], false)); + // 1: in a clip group. + let n = stand_in(&mut doc, at(1)); + let mut clip = filled_rect(&mut doc, at(1).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + objects.push(group(&mut doc, vec![Arc::new(clip), n], true)); + // 2: in another envelope. + let n = stand_in(&mut doc, at(2)); + let r = at(2); + let points = vec![kurbo::Point::new(r.x0, r.y0), kurbo::Point::new(r.x1, r.y0), kurbo::Point::new(r.x0, r.y1), kurbo::Point::new(r.x1, r.y1)]; + let kind = Box::new(vectorcraft_doc::live::EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] }); + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + objects.push(Node::new( + doc.alloc_id(), + NodeKind::Envelope { content: vec![n], kind, fidelity: 50.0, editing: false, options, frame: Box::default() }, + )); + // 3: in a group with an appearance of its own. + let n = stand_in(&mut doc, at(3)); + let mut styled = group(&mut doc, vec![n], false); + styled.appearance = fill.clone().into(); + objects.push(styled); + // 4: in a group with an opacity mask. + let n = stand_in(&mut doc, at(4)); + let mut masked = group(&mut doc, vec![n], false); + let mask = filled_rect(&mut doc, at(4).inflate(2.0, 2.0), vectorcraft_color::Color::WHITE); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true))); + objects.push(masked); + // 5: in a clipping sublayer. + let n = stand_in(&mut doc, at(5)); + let mut clip = filled_rect(&mut doc, at(5).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut sublayer = Node::layer(doc.alloc_id(), "Clipped", vectorcraft_doc::LayerColor::Preset(1)); + if let NodeKind::Layer { clip: c, children, .. } = &mut sublayer.kind { + (*c, *children) = (true, vec![Arc::new(clip), n]); + } + objects.push(sublayer); + // 6: in a repeat; 7: in a blend. + let n = stand_in(&mut doc, at(6)); + objects.push(Node::new(doc.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec::grid(vec![n], 0.0, 0.0)))); + let n = stand_in(&mut doc, at(7)); + objects.push(Node::new(doc.alloc_id(), NodeKind::Blend { children: vec![n], spec: Box::default() })); + // 8: off the page in a group with an appearance of its own (an effect could draw it onto the + // page); 9: off the page in a plain group. + let n = stand_in(&mut doc, off[0]); + let mut styled = group(&mut doc, vec![n], false); + styled.appearance = fill.into(); + objects.push(styled); + let n = stand_in(&mut doc, off[1]); + objects.push(group(&mut doc, vec![n], false)); + for n in objects { + doc.insert(Some(layer), 0, n).unwrap(); + } + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + let held = held_back(&frame, &mut view, &doc, &drawn, false); + assert_eq!(held, drawn[1..9].iter().map(|(id, ..)| *id).collect::>()); + } + + /// On a page that holds all the art that prints, which the page check also compares past the + /// page, an envelope of type reaching past the page, or lying off it, is held back; on another + /// page, the part on the page is weighed, and one off it stays live. + #[test] + fn on_a_page_that_holds_all_the_art_an_envelope_of_type_past_it_is_held_back() { + let on = Rect::new(10.0, 10.0, 30.0, 30.0); + let across = Rect::new(90.0, 40.0, 110.0, 60.0); + let off = Rect::new(120.0, 10.0, 140.0, 30.0); + let page = black_page(&[on, Rect::new(90.0, 40.0, 100.0, 60.0)]); + let (doc, drawn) = stand_ins(&[on, across, off], &[on, across, off]); + for (whole, held) in [(true, BTreeSet::from([drawn[1].0, drawn[2].0])), (false, BTreeSet::new())] { + let frame = frame_of(&page, whole); + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "whole: {whole}"); + } + // On the page by its box, past it by what it draws: a Transform effect of its own draws a + // copy of it 100 points to the right, off the page. + let page = black_page(&[on]); + let (mut doc, drawn) = stand_ins(&[on], &[on]); + let effect = + vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 100, "copies": 1}), visible: true }; + doc.node_mut(drawn[0].0).unwrap().appearance.effects.push(effect); + for (whole, held) in [(true, BTreeSet::from([drawn[0].0])), (false, BTreeSet::new())] { + let frame = frame_of(&page, whole); + let mut view = PageView::new(&frame); + assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "drawn past the page, whole: {whole}"); + } + } + + /// Where the objects that draw differently from the page are to come in as its art and the + /// envelopes of type the page doesn't hold stay live, but one of those holds the difference in + /// its box (an object that holds type isn't replaced), every envelope of type gets its drawn art + /// back and that is tried again: the layers are still used. + #[test] + fn objects_come_in_as_the_page_art_with_every_envelope_of_type_put_back_where_a_live_one_holds_a_difference() { + let (a, b, dot, other) = ( + Rect::new(10.0, 10.0, 20.0, 20.0), + Rect::new(40.0, 10.0, 50.0, 20.0), + Rect::new(27.0, 12.0, 33.0, 18.0), + Rect::new(70.0, 70.0, 90.0, 90.0), + ); + // The page draws a dot in the gap between the two rectangles of the envelope's stand-in. + let page = black_page(&[a, b, dot, other]); + let mut doc = Document::new(100.0, 100.0); + let layer = doc.layers[0].id; + let square = filled_rect(&mut doc, other, vectorcraft_color::Color::BLACK); + doc.insert(Some(layer), 0, square).unwrap(); + // The stand-in holds type, hidden; its drawn art is the two rectangles. + let style = vectorcraft_doc::CharStyle::default(); + let mut text = + Node::new(doc.alloc_id(), NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(kurbo::Point::new(12.0, 18.0), "A", style)))); + text.visible = false; + let parts = vec![ + Arc::new(filled_rect(&mut doc, a, vectorcraft_color::Color::BLACK)), + Arc::new(filled_rect(&mut doc, b, vectorcraft_color::Color::BLACK)), + ]; + let mut children = parts.clone(); + children.push(Arc::new(text)); + let live = Node::new(doc.alloc_id(), NodeKind::Group { children, clip: false }); + let id = live.id; + doc.insert(Some(layer), 0, live).unwrap(); + let art = Node::new(doc.alloc_id(), NodeKind::Group { children: parts, clip: false }); + let drawn: Vec = vec![(id, vec![art], ai::ENVELOPE_TYPE_DRAWN)]; + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &doc, false).is_err()); + let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &[(id, 0)] }; + let mut free = None; + let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free); + assert_eq!(free, Some(vec![0]), "the page doesn't hold the envelope"); + let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") }; + assert!(typed, "the envelope got its drawn art back"); + assert!(kept.node(id).is_none()); + assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}"); + } + + /// The placed files' other placement is weighed on its own: an envelope of type that the layers + /// as read draw as the page, but that a placed file moved over it at the other placement makes + /// differ, gets its drawn art back there, where that placement's differing objects then come in + /// as the page's art. + #[test] + fn the_other_placement_of_placed_files_is_weighed_on_its_own() { + let black = vectorcraft_color::Color::BLACK; + let gray = vectorcraft_color::Color::rgb(0.5, 0.5, 0.5); + let (envelope, fillers) = (Rect::new(10.0, 10.0, 30.0, 30.0), [Rect::new(40.0, 80.0, 50.0, 90.0), Rect::new(70.0, 80.0, 80.0, 90.0)]); + let placed = Rect::new(60.0, 60.0, 76.0, 76.0); + // The page draws the envelope's drawn art in two halves. + let page = black_page(&[Rect::new(10.0, 10.0, 20.0, 30.0), Rect::new(20.0, 10.0, 30.0, 30.0), placed, fillers[0], fillers[1]]); + let (mut doc, drawn) = stand_ins(&[envelope], &[envelope]); + let layer = doc.layers[0].id; + for r in fillers { + let n = filled_rect(&mut doc, r, black); + doc.insert(Some(layer), 0, n).unwrap(); + } + // Two placed files, standing in as rectangles: one 20 points above where the page has it, one + // a gray rectangle off the art. At their other placement the first is where the page has it + // and the gray one lies over half of the envelope. + let image = filled_rect(&mut doc, Rect::new(60.0, 40.0, 76.0, 56.0), black); + let strip = filled_rect(&mut doc, Rect::new(85.0, 40.0, 95.0, 60.0), gray); + // On top of the layer: the strip covers the envelope where it lies over it. + let top = doc.layers[0].children().map_or(0, Vec::len); + let (image, strip) = (doc.insert(Some(layer), top, image).unwrap(), doc.insert(Some(layer), top + 1, strip).unwrap()); + let mut other = doc.clone(); + other.node_mut(image).unwrap().transform(Affine::translate(Vec2::new(0.0, 20.0)), PAGE_MOVE); + other.node_mut(strip).unwrap().transform(Affine::translate(Vec2::new(-65.0, -30.0)), PAGE_MOVE); + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert!(held_back(&frame, &mut view, &doc, &drawn, false).is_empty()); + assert_eq!(held_back(&frame, &mut view, &other, &drawn, false), BTreeSet::from([drawn[0].0])); + let other = (other, 0); + let layers = Placements { doc: &doc, unseen: 0, other: Some(&other), drawn: &drawn, places: &[(drawn[0].0, 0)] }; + let mut free = None; + let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free); + assert_eq!(free, Some(vec![0]), "the layers as read don't hold the envelope"); + let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") }; + assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}"); + assert!(typed && kept.node(drawn[0].0).is_none(), "the envelope keeps its drawn art at the other placement"); + } + + /// Where putting back only the envelopes of type the page holds makes the layers draw as the page, + /// though putting back every live envelope's drawn art doesn't (an envelope of type the page + /// doesn't hold has stale drawn art), the layers are used as they then are: nothing differs to + /// come in as the page's art. + #[test] + fn layers_that_draw_as_the_page_with_the_held_envelopes_of_type_put_back_are_used_so() { + let (held, kept_live) = (Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0)); + let page = black_page(&[held, kept_live]); + // The first is drawn 6 points off its drawn art, which the page draws; the second draws as the + // page, its drawn art 6 points off. + let (doc, drawn) = stand_ins(&[Rect::new(16.0, 10.0, 36.0, 30.0), kept_live], &[held, Rect::new(66.0, 10.0, 86.0, 30.0)]); + let frame = frame_of(&page, false); + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &doc, false).is_err()); + let mut all = doc.clone(); + all.insert_beside(drawn.iter().map(|(id, art, _)| (*id, art.clone())).collect(), true).unwrap(); + assert!(compare(&frame, &mut view, &all, false).is_err(), "with every drawn art put back they don't draw as the page"); + let places = [(drawn[0].0, 0), (drawn[1].0, 1)]; + let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &places }; + let mut free = None; + let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free); + assert_eq!(free, Some(vec![1])); + let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") }; + assert!(typed && kept.node(drawn[0].0).is_none() && kept.node(drawn[1].0).is_some()); + assert!(!notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}"); + } } diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 34f458f328..424ac4079a 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -570,6 +570,34 @@ impl PageView { } } } + + /// Which pixels of each of the pages `on` `art` inks (draws over with an alpha above 8 of 255), + /// drawn as the layers are drawn on them, row by row. + pub(super) fn inked(&mut self, art: &Document, on: &[usize]) -> Vec<(usize, Vec)> { + self.draw(art, on) + .into_iter() + .map(|(i, drawn)| (i, drawn.pixels.as_chunks::<4>().0.iter().map(|p| u32::from(p[3]) > WHITE).collect())) + .collect() + } + + /// Of the pixels of page `i` that `marks` marks (row by row), how many `b` draws differently from + /// the page, as [`compared`] counts them: those that differ (with `reflow`, those away from type), + /// and those that differ with all their neighbors, away from type. `None` when `marks` isn't the + /// page's size. + pub(super) fn differing_in(&mut self, b: &Document, i: usize, marks: &[bool], reflow: bool) -> Option<(usize, usize)> { + self.layered(b, &[i]); + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let page = self.pages.get(i)?; + let layered = self.drawn.last()?.pages.get(i)?.as_ref()?; + let (differs, typed, solid) = differing_pixels(&page.drawn, &layered.pixels, &page.counted_type(b, page.region, reflow), &page.skipped())?; + if marks.len() != differs.len() { + return None; + } + let any = marks.iter().zip(&differs).zip(&typed).filter(|((m, d), t)| **m && **d && !(reflow && **t)).count(); + let solid = marks.iter().zip(&solid).filter(|(m, s)| **m && **s).count(); + Some((any, solid)) + } } /// The `w` × `h` pixels of `from` from pixel (`x`, `y`): `None` unless they lie in it, from a pixel. @@ -682,8 +710,27 @@ pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: /// [`compared`], and which of the pixels (row by row) are counted in its `solid` share: none when /// the drawings can't be compared. fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> (Difference, Vec) { - if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + let Some((differs, typed, solid)) = differing_pixels(x, y, areas, skip) else { return (Difference { any: 1.0, art: 1.0, solid: 1.0, typed: 0.0 }, vec![]); + }; + let area = (f64::from(x.width) * f64::from(x.height)).max(1.0); + let any = differs.iter().filter(|d| **d).count() as f64 / area; + let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count() as f64 / area; + let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art }; + (shares, solid) +} + +/// Which pixels (row by row) of two drawings of the same size differ ([`differ`], those in `skip` +/// counting as alike), which of those lie in the type's `areas`, and which differ with all their +/// neighbors away from type: [`compared`]'s pixels. `None` when the drawings can't be compared. +fn differing_pixels( + x: &vectorcraft_render::Rendered, + y: &vectorcraft_render::Rendered, + areas: &[Rect], + skip: &[Rect], +) -> Option<(Vec, Vec, Vec)> { + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { + return None; } let (w, h) = (x.width as usize, x.height as usize); // Pixels in `skip` count as alike. @@ -691,10 +738,9 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: let differs: Vec = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); if !differs.contains(&true) { - return (Difference { any: 0.0, art: 0.0, solid: 0.0, typed: 0.0 }, vec![false; w * h]); + return Some((differs, vec![false; w * h], vec![false; w * h])); } - let typed = in_areas(areas, w, h); - let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); + let typed: Vec = in_areas(areas, w, h).into_iter().zip(&differs).map(|(t, d)| t && *d).collect(); // Which pixels differ with their left and right neighbors. let mut across = vec![false; w * h]; for (k, a) in across.iter_mut().enumerate() { @@ -706,11 +752,7 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: for (k, s) in solid.iter_mut().enumerate().skip(w).take(w * h.saturating_sub(2)) { *s = !typed.get(k).copied().unwrap_or(true) && [k - w, k, k + w].iter().all(|n| across.get(*n).copied().unwrap_or(false)); } - let area = (w as f64 * h as f64).max(1.0); - let any = differs.iter().filter(|d| **d).count() as f64 / area; - let art = art as f64 / area; - let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art }; - (shares, solid) + Some((differs, typed, solid)) } /// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin @@ -879,7 +921,7 @@ const NOTABLE_DIFFERENCE: f64 = 0.002; pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001; /// How far (points) the art the layers print may reach past a page that holds it all. -const PAST_THE_PAGE: f64 = 1.0; +pub(super) const PAST_THE_PAGE: f64 = 1.0; /// Do the layers' art and the page's own look alike (where the page holds all the art that prints, /// also round it), on every page the frame has ([`Frame::more`])? An error says they don't; `Ok` has diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index ee39aec1f1..d1af3301df 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,4 @@ -use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_without_page, reid_page_art}; +use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -177,15 +177,27 @@ pub fn layered_ai_from( add_notes(&mut all, read.notes()); (visible, all, false) }; - let built = structure - .unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new)) - .and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit)); + // The envelopes of type the page doesn't hold against layers that don't draw as it, and how many + // envelopes of type the layers had. + let (mut free, mut count) = (None, 0); + let built = structure.unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new)).and_then(|structure| { + count = structure.warped_type.envelope_count(); + build_weighing(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit, &mut free) + }); // Layers that waited on the page only for their envelopes of type open alone, as they would - // have without them, each envelope keeping its drawn art, unless one opened live with the page. - let live = matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers)); + // have without them, unless one opened live with the layers drawing as the page. Alone, each + // envelope keeps its drawn art but those the page didn't hold against the layers, which open + // live as they were weighed. They were weighed on the layers as compared with the page, before + // any object of them came in as the page's art: those layers aren't used here, whatever came of + // them. + let live = free.is_none() && matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers)); if !live && let Some((mut done, notes)) = alone.and_then(|(pages, unit)| { - let structure = ai::read_over_page(&data, false, names).ok()?; + let mut structure = ai::read_over_page(&data, false, names).ok()?; + // With none free, nothing needs settling again: every envelope keeps its drawn art. + if let Some(free) = free.as_deref().filter(|free| !free.is_empty()) { + structure.warped_type.open_alone(free, count); + } build_without_page(structure, &data, &pages, unit).ok()?.ok() }) { diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index c7cdf20e11..1f634b3744 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5528,6 +5528,11 @@ fn type_envelope(keys: &str, drawn: &str) -> String { /// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in /// its contents, such as the text object's XML id. fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { + type_envelope_of(0, keys, drawn, after) +} + +/// [`type_envelope_with`] around the type of story `story`. +fn type_envelope_of(story: u32, keys: &str, drawn: &str, after: &str) -> String { let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); let mesh = format!( "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", @@ -5536,18 +5541,23 @@ fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0), corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0), ); - type_envelope_on(keys, &mesh, drawn, after) + type_envelope_on_story(story, keys, &mesh, drawn, after) } /// [`type_envelope_with`], with `shape` (on plain lines, written on `%_` ones) in the group before /// its dictionary, where the editing data saves the envelope's mesh. fn type_envelope_on(keys: &str, shape: &str, drawn: &str, after: &str) -> String { + type_envelope_on_story(0, keys, shape, drawn, after) +} + +/// [`type_envelope_on`] around the type of story `story`. +fn type_envelope_on_story(story: u32, keys: &str, shape: &str, drawn: &str, after: &str) -> String { let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; format!( "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", hidden(&format!("u\n{shape}U\n")), hidden(&format!("/ArtDictionary :\n{keys}")), - hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))), + hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(story))), ) } @@ -5594,7 +5604,12 @@ fn arc_envelope(text: &Node, bend: f64) -> Node { /// The type of the file with an envelope of type whose drawn art is `drawn`, read with its text /// document `doc`: the hidden text object it keeps, as the reader makes it. fn made_envelope_type(doc: &str) -> Node { - let art = native_with_text(&layer("Art", true, &type_envelope(&arc_keys(0.3), &format!("{}\n", square(10, 10)))), doc); + made_envelope_type_of(doc, 0) +} + +/// [`made_envelope_type`] of story `story`. +fn made_envelope_type_of(doc: &str, story: u32) -> Node { + let art = native_with_text(&layer("Art", true, &type_envelope_of(story, &arc_keys(0.3), &format!("{}\n", square(10, 10)), "")), doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); let mut text = None; r.document.walk(|n| { @@ -5685,9 +5700,9 @@ fn an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it() { /// A `.ai` whose layers would open alone, without its PDF part, waits on the PDF part when an /// envelope of type is in them, as for the other live envelopes: one opens live only where the -/// layers with it draw as the page. Where they don't, they open alone as before, the envelope -/// keeping its drawn art and its type, hidden, with the note, even though it would draw as that -/// drawn art; where they do, it opens live (#3179). +/// layers with it draw as the page. Where they don't, the page drawing nothing where the envelope +/// is, they open alone as before, the envelope keeping its drawn art and its type, hidden, with the +/// note, even though it would draw as that drawn art; where they do, it opens live (#3179). #[test] fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() { let Some(family) = installed_family() else { return }; @@ -5751,9 +5766,10 @@ fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() { } /// Layers used though they draw differently from the page (the editing data trusted) leave an -/// envelope of type its drawn art and its type, hidden, with the note, even where it draws as that -/// drawn art: one opens live only where the layers with it draw as the page. A `.ai` whose layers -/// would open alone then opens alone, as it would without the envelope (#3179). +/// envelope of type its drawn art and its type, hidden, with the note, where the page draws +/// nothing where the envelope is, even where it draws as that drawn art: one opens live only where +/// the layers with it draw as the page there. A `.ai` whose layers would open alone then opens +/// alone, as it would without the envelope (#3179). #[test] fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art() { let Some(family) = installed_family() else { return }; @@ -5789,19 +5805,27 @@ fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_dr assert!(!notes.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{notes:?}"); } -/// Layers whose objects that draw differently from the page come in as its art leave an envelope -/// of type its drawn art and its type, hidden, with the note, even where the envelope draws as the -/// page: the layers with it don't (#3179). -#[test] -fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art() { - let Some(family) = installed_family() else { return }; - let doc = type_envelope_text(&family, None); - let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); - // A square black in the layers and cyan on the page, away from the envelope. +/// The layers and the page of a file with an envelope of type that draws as its drawn art, and a +/// square black in the layers and cyan on the page, away from the envelope; the page draws the +/// envelope bent by `page_bend` (percent). +fn envelope_beside_a_differing_square(family: &str, page_bend: f64) -> (String, String) { + let doc = type_envelope_text(family, None); + let text = made_envelope_type(&doc); + let (drawn, _) = drawn_envelope(&arc_envelope(&text, 30.0)); + let (_, page) = drawn_envelope(&arc_envelope(&text, page_bend)); let body = format!("{}\n{}\n{}", square(10, 10), square_16(76, BLACK), type_envelope(&arc_keys(0.3), &drawn)); - let art = native_with_text(&layer("Art", true, &body), &doc); let cyan = "1 0 0 0 setcmykcolor 76 10 moveto 92 10 lineto 92 26 lineto 76 26 lineto closepath fill"; - let r = import(&eps(&format!("{}\n{cyan}\n{page}", page_square(10, 10)), &art)).unwrap(); + (native_with_text(&layer("Art", true, &body), &doc), format!("{}\n{cyan}\n{page}", page_square(10, 10))) +} + +/// Layers whose objects that draw differently from the page come in as its art keep an envelope of +/// type live where the envelope draws as the page: the art that differs isn't the envelope's, and +/// the layers with it then draw as the page (#3179). +#[test] +fn layers_with_objects_that_come_in_as_the_page_art_keep_an_envelope_of_type_that_draws_as_the_page_live() { + let Some(family) = installed_family() else { return }; + let (art, page) = envelope_beside_a_differing_square(&family, 30.0); + let r = import(&eps(&page, &art)).unwrap(); assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert!( r.warnings.iter().any(|n| n.starts_with("objects that drew differently from the file's own page came in as its art")), @@ -5809,12 +5833,169 @@ fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_it r.warnings ); let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); +} + +/// Layers used though they draw differently from the page (the editing data trusted) leave an +/// envelope of type its drawn art and its type, hidden, with the note, where the page draws it +/// otherwise than they do (bent the other way), though other art differs too: the page holds the +/// envelope against them where it is (#3179). +#[test] +fn trusted_layers_leave_an_envelope_of_type_the_page_draws_otherwise_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let (art, page) = envelope_beside_a_differing_square(&family, -30.0); + let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); assert!(envelopes.is_empty(), "{envelopes:?}"); assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); - assert_eq!(loose_type_names(&r.document), [None]); assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); } +/// Layers used though they draw differently from the page (the editing data trusted) keep an +/// envelope of type live where the page draws it as they do and the art that differs is elsewhere +/// (#3179). +#[test] +fn trusted_layers_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() { + let Some(family) = installed_family() else { return }; + let (art, page) = envelope_beside_a_differing_square(&family, 30.0); + let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings); +} + +/// A `.ai` whose layers would open alone, and that waits on its PDF part for an envelope of type, +/// opens alone with the envelope live where the page draws it as the layers do, though the page +/// also draws art the layers don't have: the envelope's drawn art put back wouldn't make the layers +/// draw as the page (#3179). +#[test] +fn layers_that_would_open_alone_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() { + let Some(family) = installed_family() else { return }; + let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)]; + let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})")); + // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split. + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn)); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let open = |page: Document| { + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names) + }; + // The layers alone, with the envelope's drawn art: what the page draws. + let (mut page, notes, _) = open(pdf_part(80.0, 80.0)); + assert!(envelopes_and_type(&page).0.is_empty(), "{notes:?}"); + // And a black square the layers don't have, away from the type. + add_black_square(&mut page, 80.0, 4.0); + let (d, notes, read) = open(page); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}"); + assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); +} + +/// A black square of 16 points at (`x`, `y`) put on top of `page`'s first layer. +fn add_black_square(page: &mut Document, x: f64, y: f64) { + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint)); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 16.0, y + 16.0)); + let square = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); + let layer = page.layers[0].id; + page.insert(Some(layer), 0, square).unwrap(); +} + +/// A `.ai` whose layers would open alone, with the editing data trusted, opens alone with an +/// envelope of type live where the page draws it as the layers do, though the page also draws art +/// they don't have: not from the layers compared with the page, which would say they come from the +/// editing data all the same (#3179). +#[test] +fn trusted_layers_that_would_open_alone_open_alone_with_an_envelope_of_type_the_page_doesnt_hold_live() { + let Some(family) = installed_family() else { return }; + let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)]; + let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})")); + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0)); + let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn)); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let open = |page: Document, trust: bool| { + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + crate::layered_ai_from(layers, page, vec![], false, trust, 1.0, names) + }; + let (mut page, _, _) = open(pdf_part(80.0, 80.0), false); + add_black_square(&mut page, 80.0, 4.0); + let (d, notes, read) = open(page, true); + assert!(read, "{notes:?}"); + let (envelopes, loose) = envelopes_and_type(&d); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}"); + assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}"); + assert!(notes.is_empty(), "{notes:?}"); +} + +/// Of two envelopes of type in a `.ai` whose layers would open alone, the one the page holds +/// against the layers (it draws nothing where the envelope is) keeps its drawn art and its type, +/// hidden, with the note, and the other opens live: each by its place among the envelopes of type +/// as the layers are read, both times they are (#3179). +#[test] +fn layers_that_would_open_alone_keep_live_only_the_envelopes_of_type_the_page_doesnt_hold() { + let Some(family) = installed_family() else { return }; + let stories = [ + ("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), + ("HILL\r", 0, (8170.0, 8200.0), (0.0, 0.0), 0.0), + ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0), + ]; + let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})")); + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let drawn = |story| drawn_envelope(&arc_envelope(&made_envelope_type_of(&doc, story), 30.0)).0; + let art = format!( + "u\n{}U\n{}{}", + text_object(2), + type_envelope_of(0, &arc_keys(0.3), &drawn(0), ""), + type_envelope_of(1, &arc_keys(0.3), &drawn(1), "") + ); + let file = private(&native_with_text(&layer("Art", true, &art), &doc)); + let names = crate::NameEncoding::default(); + let open = |page: Document| { + let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") }; + crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names) + }; + // The layers alone, with the drawn art of both envelopes, but the first's: the page draws + // nothing where the first is. + let (mut page, notes, _) = open(pdf_part(80.0, 80.0)); + let top = page.layers[0].clone(); + let first = top + .children() + .into_iter() + .flatten() + .filter(|n| matches!(n.kind, NodeKind::Group { .. })) + .min_by(|a, b| a.visual_bounds().map_or(f64::MAX, |r| r.y0).total_cmp(&b.visual_bounds().map_or(f64::MAX, |r| r.y0))) + .map(|n| n.id) + .unwrap_or_else(|| panic!("no drawn art: {notes:?}")); + page.remove(first).unwrap(); + let (d, notes, read) = open(page); + assert!(read, "{notes:?}"); + let (envelopes, mut loose) = envelopes_and_type(&d); + assert_eq!(envelopes, [vec!["HILL".to_string()]], "{notes:?}"); + loose.sort(); + assert_eq!(loose, [("FLUX".to_string(), false), ("Note".to_string(), true)], "{notes:?}"); + assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}"); +} + /// An envelope of type whose type a visibility variable binds keeps its drawn art, as one whose type /// a text variable binds does: the binding stays the hidden type's, which the live envelope would /// take the place of (#3179). diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 042a7ed2d2..00bb29d12f 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -115,6 +115,7 @@ const CONIC: &str = "conic gradients are written as solid wedges clipped to the const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)"; const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (legacy .ai gradients only pad)"; const SPREAD_CAPPED: &str = "gradients that repeat or reflect are written with at most 256 stops; they pad past those"; +const HUE_STOPS: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256; legacy .ai gradients mix their stops' colors directly)"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 76e96f5aa9..6b2f8335b8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -198,6 +198,46 @@ fn many_repeats_are_written_within_the_stops_the_reader_reads() { assert_eq!(stops, Some(written), "every stop written is read"); } +/// The format's gradients mix their stops' colors directly: a gradient mixed around the hue wheel +/// is written with the colors on its way around the wheel as stops, no more than the reader +/// reads, with a warning, and opens with the colors it had. +#[test] +fn hue_wheel_gradients_are_written_with_the_stops_on_their_curve() { + use vectorcraft_color::GradientMethod; + for (method, middle) in [(GradientMethod::HueCounterclockwise, [0.0, 1.0, 0.0]), (GradientMethod::HueClockwise, [1.0, 0.0, 1.0])] { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let g = GradientPaint::new(Gradient { method, ..Gradient::new(GradientKind::Linear, stops) }); + let want = g.gradient.clone(); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == HUE_STOPS), "{method:?}: {:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap(); + let written = def.matches(" %_Bs\n").count(); + // The two ends and each sixth of the wheel between them, where the way turns in RGB. + assert!((3..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops"); + let read = crate::import(&out.bytes).unwrap().document; + let mut back = None; + read.walk(|n| { + if let (None, Paint::Gradient(g)) = (&back, n.appearance.fill_paint()) { + back = Some(g.gradient.clone()); + } + }); + let back = back.unwrap(); + assert!(back.method.is_classic(), "{method:?}"); + let mid = back.sample(0.5).0.to_rgb(); + assert!(mid.iter().zip(middle).all(|(a, b)| (a - b).abs() < 0.03), "{method:?}: the middle is {mid:?}"); + for k in 0..=100 { + let t = k as f32 / 100.0; + let (a, b) = (want.sample(t).0.to_rgb(), back.sample(t).0.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03), "{method:?} at {t}: {a:?} vs {b:?}"); + } + } +} + /// A radial gradient repeated out from a center far from the shape is written with its ramp points /// (percent) to five decimals, so its runs keep their places hundreds of runs out. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 06d9e0ddcc..20f8b32b96 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -9,7 +9,7 @@ use super::{ ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; -use super::{CONIC, CONIC_WEDGES, DIAMOND, SPREAD, SPREAD_CAPPED}; +use super::{CONIC, CONIC_WEDGES, DIAMOND, HUE_STOPS, SPREAD, SPREAD_CAPPED}; pub(super) struct Writer<'a> { doc: &'a Document, @@ -693,6 +693,16 @@ impl<'a> Writer<'a> { /// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center, /// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`. fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String { + // A gradient mixed around the hue wheel (the format's gradients mix their stops' colors + // directly): the stops on its curve, no more than the reader reads, in a definition of + // its own. + if g.gradient.method.is_hue() { + self.warn(HUE_STOPS); + let curve = g.gradient.as_classic_curve(); + let gradient = if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() }; + let p = GradientPaint { gradient, swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } // A gradient that repeats or reflects (the format's gradients pad past their ends): its // stops repeated as many times as `bounds` needs, in a definition of its own, no more // stops than the reader reads. diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 0ec561dd31..c20c5847a2 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -42,6 +42,7 @@ const SPOT_GRADIENTS_L2: &str = "gradients mixing a spot color with other inks are written in process colors at PostScript Level 2 (Level 3 keeps the spot inks)"; const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks"; const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors"; +const SPOT_GRADIENTS_HUE: &str = "gradients of spot colors mixed around the hue wheel are written in process colors"; const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)"; const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)"; const SPREAD_CAPPED: &str = @@ -913,8 +914,8 @@ impl<'a> Scene<'a> { /// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates /// print with its ink. An off-center midpoint is a stop /// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and, - /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method; - /// it is then written in process colors. + /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method + /// or around the hue wheel; it is then written in process colors. fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> { let doc = self.doc; let spot_of = |s: &vectorcraft_color::GradientStop| -> Option { @@ -936,7 +937,7 @@ impl<'a> Scene<'a> { return None; } if !g.method.is_classic() { - self.warn(SPOT_GRADIENTS_PERCEPTUAL); + self.warn(if g.method.is_hue() { SPOT_GRADIENTS_HUE } else { SPOT_GRADIENTS_PERCEPTUAL }); return None; } if names.len() > MAX_DEVICE_N { @@ -1093,6 +1094,14 @@ impl<'a> Scene<'a> { /// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]). fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { let geom = g.resolve(bounds); + // One mixed around the hue wheel that reflects is repeated from its curve's stops, which + // keep no swatch links: the warning [`Self::ink_stops`] gives the others. + if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect { + let doc = self.doc; + if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { + self.warn(SPOT_GRADIENTS_HUE); + } + } // A linear, radial or diamond gradient that repeats or reflects: PostScript's shadings // pad past their ends, so its stops are repeated as many times as the area needs. if let Some(padded) = g.gradient.padded_over(&geom, area) { diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 0d5649ead5..c98cb9689a 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -673,6 +673,53 @@ fn perceptual_gradients_of_spot_inks_are_written_in_process_colors() { assert!(text(&export(&d, &opts(&d), None, None).unwrap()).contains("/ColorSpace VCdn0")); } +/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a +/// gradient of spot inks mixed that way is written in process colors, with its own warning, as +/// the same gradient without spot inks is. +#[test] +fn hue_wheel_gradients_of_spot_inks_are_written_in_process_colors() { + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + let stops = vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, "Leaf Ink", LEAF, 1.0)]; + let mut hue = Gradient::new(GradientKind::Linear, stops.clone()); + hue.method = method; + // Reflecting, it is written from the stops on its curve, and warns all the same. + let reflected = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..hue.clone() }; + let r = spot_gradient_doc_of(reflected); + let reflected_out = export(&r, &opts(&r), None, None).unwrap(); + let reflected_page = checked_page(&text(&reflected_out)); + assert!(!reflected_page.contains("VCdn") && !reflected_page.contains("VCsp"), "{method:?} reflected: {reflected_page}"); + assert!( + reflected_out.warnings.iter().any(|w| w.contains("mixed around the hue wheel are written in process colors")), + "{method:?} reflected: {:?}", + reflected_out.warnings + ); + let d = spot_gradient_doc_of(hue); + let out = export(&d, &opts(&d), None, None).unwrap(); + let page = checked_page(&text(&out)); + assert!(!page.contains("VCdn") && !page.contains("VCsp"), "{method:?}: {page}"); + assert!(page.contains("/ColorSpace /DeviceCMYK"), "{method:?}: {page}"); + assert!( + out.warnings.iter().any(|w| w.contains("gradients of spot colors mixed around the hue wheel are written in process colors")), + "{method:?}: {:?}", + out.warnings + ); + assert!(!out.warnings.iter().any(|w| w.contains("Perceptual")), "{method:?}: {:?}", out.warnings); + let process = |c: Color| GradientStop::new(0.0, c); + let mut plain = Gradient::new(GradientKind::Linear, vec![process(GOLD), GradientStop { offset: 1.0, ..process(LEAF) }]); + plain.method = method; + let p = spot_gradient_doc_of(plain); + let plain_out = export(&p, &opts(&p), None, None).unwrap(); + assert!(!plain_out.warnings.iter().any(|w| w.contains("hue wheel")), "{method:?}: {:?}", plain_out.warnings); + let plain_page = checked_page(&text(&plain_out)); + let shading = |s: &str| -> Option { + let i = s.find("/ShadingType")?; + Some(s[i..i + s[i..].find("shfill")?].to_string()) + }; + assert!(shading(&page).is_some_and(|s| s.contains("/Bounds")), "{method:?}: {page}"); + assert_eq!(shading(&page), shading(&plain_page), "{method:?}"); + } +} + #[test] fn rgb_is_written_as_cmyk_when_asked_and_in_cmyk_documents() { let d = doc_with(|d| vec![red_rect(d)]); diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs index 4ef9b82105..94ee226d57 100644 --- a/crates/js/src/builtins/extensions.rs +++ b/crates/js/src/builtins/extensions.rs @@ -388,18 +388,33 @@ fn last_error(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult JsResult { - let line = interp.script_frames().last().map_or(0, |(code, pc, _)| code.line_column(pc.saturating_sub(1)).0); + let line = interp.script_frames().last().map_or(0, |(code, pc, _)| place(interp.locator.as_ref(), code, pc).1); Ok(Value::Number(f64::from(line))) } +/// Where the instruction before `pc` in `code` came from: the file the realm's locator names +/// (`None` without one) and the line there, or the line in the compiled source without a locator. +fn place(locator: Option<&crate::realm::SourceLocator>, code: &Code, pc: usize) -> (Option, u32) { + let line = code.line_column(pc.saturating_sub(1)).0; + match locator { + Some(locate) => { + let (file, line) = locate(code.source.tag, line); + (Some(file), line) + } + None => (None, line), + } +} + /// The most frames `$.stack` lists, and the most characters of a function's name it shows. const STACK_FRAMES: usize = 64; const STACK_NAME_UNITS: usize = 64; -/// The script's frames, outermost first, one a line: the function's name and the line it is at. +/// The script's frames, outermost first, one a line: the function's name and the line it is at, +/// as `name:line`, or as `name@file:line` with the file the realm's locator names. fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { let frames: Vec<(Rc, usize, bool)> = interp.script_frames().map(|(c, pc, f)| (c.clone(), pc, f)).collect(); let skip = frames.len().saturating_sub(STACK_FRAMES); + let locator = interp.locator.clone(); let mut text = String::new(); if skip > 0 { text.push_str("…\n"); @@ -407,8 +422,10 @@ fn stack(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult for (code, pc, function) in frames.iter().skip(skip) { interp.use_fuel()?; let name = if *function { function_name(code) } else { "[global]".to_string() }; - let line = code.line_column(pc.saturating_sub(1)).0; - text.push_str(&format!("{name}:{line}\n")); + match place(locator.as_ref(), code, *pc) { + (Some(file), line) => text.push_str(&format!("{name}@{file}:{line}\n")), + (None, line) => text.push_str(&format!("{name}:{line}\n")), + } } let mut out = Builder::new(); out.push_str(interp, &text)?; diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 8336643e4d..4807856bf5 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -219,6 +219,32 @@ fn script_items_read_the_global_object_the_last_error_the_line_and_the_stack() { assert!(thrown(&mut r, "'use strict'; h.line = 1").starts_with("TypeError"), "the items cannot be assigned"); } +#[test] +fn the_line_and_the_stack_give_the_place_the_source_locator_maps_each_frame_to() { + let mut r = realm(); + let items = [("line", ScriptItem::Line), ("stack", ScriptItem::Stack)].into_iter().map(|(n, i)| (n.to_string(), HostItem::Script(i))).collect(); + r.define_host_object("h", items).unwrap(); + let asked: Rc>> = Rc::default(); + let seen = asked.clone(); + r.set_source_locator(Rc::new(move |tag, line| { + seen.borrow_mut().push((tag, line)); + let file = if tag == UNKNOWN_SOURCE { "[eval]".to_string() } else { format!("f{tag}") }; + (file, line + 100) + })); + let source = "function inner() {\n return h.stack;\n}\n\ninner()"; + let stack = text(&mut r, source); + let tag = r.script_tag(); + assert_eq!(stack, format!("[global]@f{tag}:105\ninner@f{tag}:102\n")); + assert_eq!(*asked.borrow(), [(tag, 5), (tag, 2)], "each frame's tag and its line in the compiled source"); + assert_eq!(r.run("\n\n h.line").unwrap(), JsValue::Number(103.0)); + let tag = r.script_tag(); + let stack = text(&mut r, "\neval('\\n\\nh.stack')"); + assert_eq!(stack, format!("[global]@f{}:102\n[global]@[eval]:103\n", tag + 1), "code eval compiles has no tag of its own"); + let deep = "function d(n) { return n ? d(n - 1) : h.stack; } var s = d(100).split('\\n'); s.length + ',' + s[0] + ',' + s[1]"; + let tag = r.script_tag() + 1; + assert_eq!(text(&mut r, deep), format!("66,…,d@f{tag}:101"), "the frames past 64 are still left out"); +} + #[test] fn a_host_property_reads_its_getter_and_gives_assignments_to_its_setter() { let mut r = Realm::new(); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 22a365b615..edc5282fe5 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -232,6 +232,8 @@ pub(crate) struct Interp { pub(crate) evaluated: u32, /// The tag of the source the last run's error was raised in. pub(crate) error_tag: u32, + /// Where `$.line` and `$.stack` find the file and line of a compiled line. + pub(crate) locator: Option, /// The mark of the host error ([`crate::HostError::Marked`]) whose exception or limit is /// propagating. pub(crate) thrown_mark: Option, @@ -469,6 +471,7 @@ impl Interp { source_tag: crate::realm::UNKNOWN_SOURCE, evaluated: 0, error_tag: crate::realm::UNKNOWN_SOURCE, + locator: None, thrown_mark: None, finally_marks: Vec::new(), finally_sites: Vec::new(), diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 2e61f2e1bb..1561d71dfe 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -83,7 +83,7 @@ pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; pub use host::{Arg, CallSite, Data, DataLimits, HostError, HostFunction, HostItem, ScriptItem, SiteFunction}; pub use lexer::{Goal, Lexer}; pub use number::to_string as number_to_string; -pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, UNKNOWN_SOURCE}; +pub use realm::{Interrupt, JsValue, LimitKind, Limits, MAX_UNBROKEN_FUEL, Progress, Realm, RunError, RunErrorKind, SourceLocator, UNKNOWN_SOURCE}; pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; /// How a script is read. diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 077f3b19b6..4146ae891b 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -63,6 +63,10 @@ pub enum LimitKind { /// evaluator's: code that `eval` or `Function` compiled, or an error with no place. pub const UNKNOWN_SOURCE: u32 = u32::MAX; +/// The file and line that line `line` of the source with tag `tag` ([`Realm::error_tag`]) came +/// from, as `$.line` and `$.stack` give them ([`Realm::set_source_locator`]). +pub type SourceLocator = std::rc::Rc (String, u32)>; + /// The source tag after `tag`. Once the tags are used up, every later source gets /// [`UNKNOWN_SOURCE`], so no two sources share a tag. pub(crate) fn next_tag(tag: u32) -> u32 { @@ -395,6 +399,13 @@ impl Realm { self.interp.interrupt = interrupt.0.clone(); } + /// Has `$.line` give the line `locate` maps the running line to, and `$.stack` give each + /// frame's line that way, after the file `locate` names: `function@file:line`. Without a + /// locator they give the line of the compiled source, and `$.stack` names no file. + pub fn set_source_locator(&mut self, locate: SourceLocator) { + self.interp.locator = Some(locate); + } + /// Which source the last error's `line` and `column` are in: 0 for the script last given to /// [`Realm::run`] or [`Realm::start`], `n` for the `n`th source a /// [`crate::HostItem::Evaluator`] gave since then, and [`UNKNOWN_SOURCE`] otherwise, which diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 9cd797f48a..d3a5245793 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -8,8 +8,8 @@ use crate::builtins::{arg, constructor, data, method, native}; use crate::heap::{ Class, ENUMERABLE, Key, NativeFn, NativeKind, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, WRITABLE, XmlKind, XmlName, XmlNs, key, string_bytes, }; -use crate::interp::{Abrupt, Interp, JsResult}; -use crate::string_builtins::{new_array, set_element}; +use crate::interp::{Abrupt, HeldBytes, Interp, JsResult}; +use crate::string_builtins::{flat_charged, new_array, push_held, reserve_units, set_element}; use crate::value::{Str, Value}; use crate::xml::{ Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable, @@ -184,6 +184,13 @@ fn as_qname(interp: &Interp, v: &Value) -> Option<(XmlName, bool)> { } } +/// The code units of `v` as a string. A rope is flattened only after the bytes of its copy are +/// reserved. +fn str_arg(interp: &mut Interp, v: &Value) -> JsResult { + let s = interp.to_str(v)?; + flat_charged(interp, &s) +} + /// The namespace `new Namespace(...)` makes from its arguments (ECMA-357 section 13.2.2). fn build_namespace(interp: &mut Interp, args: &[Value]) -> JsResult { match args { @@ -197,7 +204,7 @@ fn build_namespace(interp: &mut Interp, args: &[Value]) -> JsResult { { return Ok(XmlNs { prefix: name.prefix, uri: u }); } - let u = interp.to_str(uri)?.flat(); + let u = str_arg(interp, uri)?; let prefix = if u.is_empty() { Some(empty()) } else { None }; Ok(XmlNs { prefix, uri: u }) } @@ -205,7 +212,7 @@ fn build_namespace(interp: &mut Interp, args: &[Value]) -> JsResult { let qname_uri = as_qname(interp, uri).and_then(|(n, _)| n.uri); let u = match qname_uri { Some(u) => u, - None => interp.to_str(uri)?.flat(), + None => str_arg(interp, uri)?, }; if u.is_empty() { if matches!(prefix_value, Value::Undefined) || interp.to_str(prefix_value)?.len() == 0 { @@ -216,7 +223,7 @@ fn build_namespace(interp: &mut Interp, args: &[Value]) -> JsResult { if matches!(prefix_value, Value::Undefined) { return Ok(XmlNs { prefix: None, uri: u }); } - let p = interp.to_str(prefix_value)?.flat(); + let p = str_arg(interp, prefix_value)?; Ok(XmlNs { prefix: is_ncname(&p).then_some(p), uri: u }) } } @@ -256,7 +263,7 @@ fn build_qname(interp: &mut Interp, args: &[Value]) -> JsResult<(XmlName, bool)> } else if matches!(name_value, Value::Undefined) { empty() } else { - interp.to_str(&name_value)?.flat() + str_arg(interp, &name_value)? }; let (uri, prefix) = match namespace { Some(Value::Undefined) | None => { @@ -290,7 +297,16 @@ fn qname_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> Js let Some((name, attribute)) = as_qname(interp, &this) else { return Err(interp.type_error("QName.prototype.toString needs a QName")); }; - let mut units: Vec = Vec::new(); + let uri_len = match &name.uri { + None => 3, + Some(u) if !u.is_empty() => u.len().saturating_add(2), + _ => 0, + }; + let len = usize::from(attribute).saturating_add(uri_len).saturating_add(name.local.len()); + let bytes = string_bytes(len); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + let mut units: Vec = Vec::with_capacity(len); if attribute { units.push(0x40); } @@ -303,9 +319,6 @@ fn qname_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> Js _ => {} } units.extend_from_slice(&name.local); - let bytes = string_bytes(units.len()); - interp.reserve_heap(bytes)?; - interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); Ok(Value::String(Str::from_units(units))) } @@ -324,7 +337,7 @@ fn to_xml(interp: &mut Interp, v: &Value) -> JsResult { Err(interp.type_error("an XMLList converts to XML only when it has one item")) } Xv::Other => { - let text = if v.is_nullish() { empty() } else { interp.to_str(v)?.flat() }; + let text = if v.is_nullish() { empty() } else { str_arg(interp, v)? }; interp.xml_from_text(&text) } } @@ -355,7 +368,7 @@ fn xml_list_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) Xv::Node(n) => i.list_push(out, n)?, _ if v.is_nullish() => {} _ => { - let text = i.to_str(&v)?.flat(); + let text = str_arg(i, &v)?; for node in i.xml_parse(&text)? { i.list_push(out, node)?; } @@ -366,7 +379,7 @@ fn xml_list_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) } fn is_xml_name(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { - let s = interp.to_str(&arg(args, 0))?.flat(); + let s = str_arg(interp, &arg(args, 0))?; Ok(Value::Bool(is_script_xml_name(&s))) } @@ -440,10 +453,16 @@ fn this_id(interp: &mut Interp, this: &Value) -> JsResult { } } -fn nodes_of(interp: &mut Interp, this: &Value) -> JsResult> { +/// The nodes of `this`: the node itself, or a copy of the items of an XMLList, whose bytes `held` +/// holds against the heap limit first. +fn nodes_of(interp: &mut Interp, held: &mut HeldBytes, this: &Value) -> JsResult> { match interp.classify(this) { Xv::Node(n) => Ok(vec![n]), - Xv::List(l) => Ok(interp.xml_list(l)?.items.clone()), + Xv::List(l) => interp.rooted(std::slice::from_ref(this), |interp| { + let count = interp.xml_list(l)?.items.len(); + held.add(interp, count.saturating_mul(size_of::()))?; + Ok(interp.xml_list(l)?.items.clone()) + }), Xv::Other => Err(not_xml_method(interp)), } } @@ -464,8 +483,14 @@ fn attribute_name(interp: &mut Interp, v: &Value) -> JsResult { if matches!(v, Value::Undefined | Value::Null | Value::Bool(_) | Value::Number(_)) { return Err(interp.type_error("the value is not an XML attribute name")); } - let local = interp.to_str(v)?.flat(); - let local = local.strip_prefix(&[0x40u16]).map_or_else(|| local.clone(), Rc::from); + let local = str_arg(interp, v)?; + let local = match local.strip_prefix(&[0x40u16]) { + Some(rest) => { + reserve_units(interp, rest.len())?; + Rc::from(rest) + } + None => local, + }; let uri = if *local == *STAR { None } else { Some(empty()) }; Ok(NameRef { uri, local, attribute: true }) } @@ -510,7 +535,8 @@ fn child(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult< match interp.xml_access(&p)? { Access::Name(n) => Ok(Value::Object(interp.xml_select(id, &n)?)), Access::Index(i) => { - let nodes = nodes_of(interp, &this)?; + let mut held = HeldBytes::new(interp); + let nodes = nodes_of(interp, &mut held, &this)?; interp.rooted(std::slice::from_ref(&this), |it| { let out = it.new_xml_list(Some(id), None)?; it.xml_keep(out); @@ -535,7 +561,8 @@ fn select_children( keep: impl Fn(&Interp, ObjId) -> JsResult, ) -> JsResult { let id = this_id(interp, this)?; - let nodes = nodes_of(interp, this)?; + let mut held = HeldBytes::new(interp); + let nodes = nodes_of(interp, &mut held, this)?; interp.rooted(std::slice::from_ref(this), |it| { let out = it.new_xml_list(Some(id), target_name)?; it.xml_keep(out); @@ -666,34 +693,64 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> } } -/// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit -/// of fuel for each node it climbs through and for each declaration it reads. -pub(crate) fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { - let mut out: Vec = Vec::new(); - let mut seen: HashSet> = HashSet::new(); - let mut cur = x; - while let Some(y) = cur { - interp.use_fuel()?; - let (list, parent) = { - let node = interp.xml_node(y)?; - (node.ns.clone(), node.parent) - }; - for ns in list { - interp.use_fuel()?; - if seen.insert(ns.prefix.clone()) { - out.push(ns); - } - } - cur = parent; - } - Ok(out) +/// The namespaces in scope at a node, nearest declarations first, one for each prefix. The bytes of +/// the list and of the table of prefixes that built it stay held against the heap limit until the +/// scope is dropped. +pub(crate) struct Scope { + pub(crate) list: Vec, + _held: HeldBytes, } -fn namespaces_array(interp: &mut Interp, list: Vec) -> JsResult { +/// Holds the bytes of a full hash table of `len` entries of `entry` bytes each, and returns how +/// many more entries to reserve room for. Each new entry is counted four times with a control +/// byte, which covers the empty slots the table keeps and the old table while it is rehashed. +fn grow_table(interp: &mut Interp, held: &mut HeldBytes, len: usize, entry: usize) -> JsResult { + let more = len.max(4); + held.add(interp, more.saturating_mul(4).saturating_mul(entry.saturating_add(1)).saturating_add(16))?; + Ok(more) +} + +/// The namespace `y` declares at index `i`. +fn declared_at(interp: &mut Interp, y: ObjId, i: usize) -> JsResult> { + Ok(interp.xml_node(y)?.ns.get(i).cloned()) +} + +/// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit +/// of fuel for each node it climbs through and for each declaration it reads. +pub(crate) fn scope_of(interp: &mut Interp, x: Option) -> JsResult { + let root: Vec = x.map(Value::Object).into_iter().collect(); + interp.rooted(&root, |interp| { + let mut held = HeldBytes::new(interp); + let mut out: Vec = Vec::new(); + let mut seen: HashSet> = HashSet::new(); + let mut cur = x; + while let Some(y) = cur { + interp.use_fuel()?; + let mut i = 0; + while let Some(ns) = declared_at(interp, y, i)? { + interp.use_fuel()?; + i += 1; + if seen.contains(&ns.prefix) { + continue; + } + if seen.len() == seen.capacity() { + let more = grow_table(interp, &mut held, seen.len(), size_of::>())?; + seen.reserve(more); + } + seen.insert(ns.prefix.clone()); + push_held(interp, &mut held, &mut out, ns)?; + } + cur = interp.xml_node(y)?.parent; + } + Ok(Scope { list: out, _held: held }) + }) +} + +fn namespaces_array(interp: &mut Interp, list: &[XmlNs]) -> JsResult { interp.rooted(&[], |i| { let a = new_array(i); - for (at, ns) in list.into_iter().enumerate() { - let v = new_namespace(i, ns)?; + for (at, ns) in list.iter().enumerate() { + let v = new_namespace(i, ns.clone())?; set_element(i, a, at, v)?; } Ok(Value::Object(a)) @@ -702,28 +759,46 @@ fn namespaces_array(interp: &mut Interp, list: Vec) -> JsResult { fn in_scope_namespaces(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { let x = this_node(interp, &this)?; - let list = scope_of(interp, Some(x))?; - namespaces_array(interp, list) + let scope = scope_of(interp, Some(x))?; + namespaces_array(interp, &scope.list) } fn namespace_declarations(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { let x = this_node(interp, &this)?; if interp.xml_kind(x)? != XmlKind::Element { - return namespaces_array(interp, Vec::new()); + return namespaces_array(interp, &[]); } - let parent = interp.xml_node(x)?.parent; - let ancestors: HashMap, Key> = scope_of(interp, parent)?.into_iter().map(|a| (a.prefix, a.uri)).collect(); - let declared: Vec = interp.xml_node(x)?.ns.iter().filter(|ns| ancestors.get(&ns.prefix) != Some(&ns.uri)).cloned().collect(); - namespaces_array(interp, declared) + interp.rooted(&[Value::Object(x)], |interp| { + let parent = interp.xml_node(x)?.parent; + let scope = scope_of(interp, parent)?; + let mut held = HeldBytes::new(interp); + let mut ancestors: HashMap, Key> = HashMap::new(); + for a in &scope.list { + if ancestors.len() == ancestors.capacity() { + let more = grow_table(interp, &mut held, ancestors.len(), size_of::<(Option, Key)>())?; + ancestors.reserve(more); + } + ancestors.insert(a.prefix.clone(), a.uri.clone()); + } + let mut declared: Vec = Vec::new(); + let mut i = 0; + while let Some(ns) = declared_at(interp, x, i)? { + i += 1; + if ancestors.get(&ns.prefix) != Some(&ns.uri) { + push_held(interp, &mut held, &mut declared, ns)?; + } + } + namespaces_array(interp, &declared) + }) } fn namespace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { let x = this_node(interp, &this)?; let scope = scope_of(interp, Some(x))?; if let Some(p) = args.first() { - let p = interp.to_str(p)?.flat(); - return match scope.into_iter().find(|ns| ns.prefix.as_deref() == Some(&*p)) { - Some(ns) => new_namespace(interp, ns), + let p = str_arg(interp, p)?; + return match scope.list.iter().find(|ns| ns.prefix.as_deref() == Some(&*p)) { + Some(ns) => new_namespace(interp, ns.clone()), None => Ok(Value::Undefined), }; } @@ -734,9 +809,10 @@ fn namespace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsRes let Some(name) = node.name.clone() else { return Ok(Value::Null) }; let uri = name.uri.clone().unwrap_or_else(empty); let found = scope + .list .iter() .find(|ns| ns.uri == uri && ns.prefix == name.prefix) - .or_else(|| scope.iter().find(|ns| ns.uri == uri)) + .or_else(|| scope.list.iter().find(|ns| ns.uri == uri)) .cloned() .unwrap_or(XmlNs { prefix: name.prefix, uri }); new_namespace(interp, found) @@ -777,7 +853,8 @@ fn node_kind(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult } fn normalize(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { - for node in nodes_of(interp, &this)? { + let mut held = HeldBytes::new(interp); + for node in nodes_of(interp, &mut held, &this)? { if interp.xml_kind(node)? == XmlKind::Element { interp.node_normalize(node)?; } @@ -806,7 +883,7 @@ fn parent(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult JsResult { let p = arg(args, 0); - let s = interp.to_str(&p)?.flat(); + let s = str_arg(interp, &p)?; match interp.classify(&this) { Xv::Node(_) => Ok(Value::Bool(canonical_index(&s) == Some(0))), Xv::List(l) => Ok(Value::Bool(canonical_index(&s).is_some_and(|i| (i as usize) < interp.list_len(l).unwrap_or(0)))), @@ -814,6 +891,11 @@ fn property_is_enumerable(interp: &mut Interp, this: Value, args: &[Value], _: b } } +/// The attribute `x` holds at index `i`. +fn attr_at(interp: &mut Interp, x: ObjId, i: usize) -> JsResult> { + Ok(interp.xml_node(x)?.attrs.get(i).copied()) +} + /// [[AddInScopeNamespace]] (ECMA-357 section 9.1.1.13). fn add_in_scope(interp: &mut Interp, x: ObjId, ns: XmlNs) -> JsResult<()> { if interp.xml_kind(x)? != XmlKind::Element { @@ -823,16 +905,14 @@ fn add_in_scope(interp: &mut Interp, x: ObjId, ns: XmlNs) -> JsResult<()> { if !ns_binding_allowed(&prefix, &ns.uri) { return Err(interp.type_error(RESERVED_NS_BINDING)); } - let node = interp.xml_node(x)?; - if prefix.is_empty() && node.name.as_ref().and_then(|n| n.uri.as_ref()).is_some_and(|u| u.is_empty()) { + if prefix.is_empty() && interp.xml_node(x)?.name.as_ref().and_then(|n| n.uri.as_ref()).is_some_and(|u| u.is_empty()) { return Ok(()); } - if !node.ns.iter().any(|m| m.prefix.as_ref() == Some(&prefix)) { + if !interp.xml_node(x)?.ns.iter().any(|m| m.prefix.as_ref() == Some(&prefix)) { let bytes = crate::heap::XML_NS_BYTES; interp.reserve_heap(bytes)?; interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); } - let attrs = interp.xml_node(x)?.attrs.clone(); let node = interp.xml_node_mut(x)?; node.ns.retain(|m| m.prefix.as_ref() != Some(&prefix)); node.ns.push(ns); @@ -841,8 +921,10 @@ fn add_in_scope(interp: &mut Interp, x: ObjId, ns: XmlNs) -> JsResult<()> { { name.prefix = None; } - for a in attrs { + let mut i = 0; + while let Some(a) = attr_at(interp, x, i)? { interp.use_fuel()?; + i += 1; if let Some(name) = &mut interp.xml_node_mut(a)?.name && name.prefix.as_ref() == Some(&prefix) { @@ -915,25 +997,31 @@ fn insert_child_before(interp: &mut Interp, this: Value, args: &[Value], _: bool insert_relative(interp, this, args, false) } -/// One element of a `removeNamespace` walk; true when the walk goes on to its children. -fn remove_ns_from(interp: &mut Interp, x: ObjId, ns: &XmlNs) -> JsResult { - let node = interp.xml_node(x)?; - let attrs = node.attrs.clone(); - if node.name.as_ref().and_then(|n| n.uri.as_ref()) == Some(&ns.uri) { - return Ok(false); +/// Whether the name of element `x` or of one of its attributes is in `ns`, which keeps `ns` on `x` +/// and its descendants in a `removeNamespace` walk. +fn ns_in_use(interp: &mut Interp, x: ObjId, ns: &XmlNs) -> JsResult { + if interp.xml_node(x)?.name.as_ref().and_then(|n| n.uri.as_ref()) == Some(&ns.uri) { + return Ok(true); } - for a in attrs { + let mut i = 0; + while let Some(a) = attr_at(interp, x, i)? { interp.use_fuel()?; + i += 1; if interp.xml_node(a)?.name.as_ref().and_then(|n| n.uri.as_ref()) == Some(&ns.uri) { - return Ok(false); + return Ok(true); } } + Ok(false) +} + +/// Removes `ns` from the declarations of element `x`. +fn remove_ns_from(interp: &mut Interp, x: ObjId, ns: &XmlNs) -> JsResult<()> { let list = &mut interp.xml_node_mut(x)?.ns; match &ns.prefix { None => list.retain(|m| m.uri != ns.uri), Some(p) => list.retain(|m| !(m.uri == ns.uri && m.prefix.as_ref() == Some(p))), } - Ok(true) + Ok(()) } fn remove_namespace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { @@ -942,23 +1030,31 @@ fn remove_namespace(interp: &mut Interp, this: Value, args: &[Value], _: bool) - return Ok(this); } let ns = build_namespace(interp, std::slice::from_ref(&arg(args, 0)))?; - if !remove_ns_from(interp, x, &ns)? { - return Ok(this); - } - let mut stack = vec![(x, 0usize)]; - while let Some(&(node, next)) = stack.last() { - interp.use_fuel()?; - let Some(c) = interp.xml_node(node)?.children.get(next).copied() else { - stack.pop(); - continue; - }; - if let Some(frame) = stack.last_mut() { - frame.1 += 1; + interp.rooted(&[Value::Object(x)], |interp| { + if ns_in_use(interp, x, &ns)? { + return Ok(()); } - if interp.xml_kind(c)? == XmlKind::Element && remove_ns_from(interp, c, &ns)? { - stack.push((c, 0)); + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + remove_ns_from(interp, x, &ns)?; + while let Some(&(node, next)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(node)?.children.get(next).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + if interp.xml_kind(c)? != XmlKind::Element || ns_in_use(interp, c, &ns)? { + continue; + } + push_held(interp, &mut held, &mut stack, (c, 0))?; + remove_ns_from(interp, c, &ns)?; } - } + Ok(()) + })?; Ok(this) } @@ -989,6 +1085,9 @@ fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul }; let matched = i.xml_select(x, &NameRef { attribute: false, ..n })?; i.xml_keep(matched); + let mut held = HeldBytes::new(i); + let count = i.xml_list(matched)?.items.len(); + held.add(i, count.saturating_mul(size_of::()))?; let items = i.xml_list(matched)?.items.clone(); if items.is_empty() { return Ok(()); @@ -1020,7 +1119,7 @@ fn set_local_name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> let v = arg(args, 0); let local = match as_qname(interp, &v) { Some((n, _)) => n.local, - None => interp.to_str(&v)?.flat(), + None => str_arg(interp, &v)?, }; checked_local(interp, &local)?; if kind == XmlKind::Attribute diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index b23aafaf42..fc909351a6 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -351,6 +351,10 @@ fn the_xml_methods_behave_as_specified() { "var x = ; x.b[0].inScopeNamespaces().length === 1 && x.namespaceDeclarations()[0].prefix === 'p' && x.b[0].namespaceDeclarations().length === 0", ); truth("var x = ; x.namespace('p').uri === 'u' && x.namespace('q') === undefined && x.namespace().uri === ''"); + truth( + "var x = ; var c = x.b.c[0]; var s = c.inScopeNamespaces(); var d = x.b[0].namespaceDeclarations(); \ + s.length === 2 && c.namespace('p').uri === 'w' && c.namespace('q').uri === 'v' && c.namespace('z') === undefined && d.length === 1 && d[0].prefix === 'p' && d[0].uri === 'w'", + ); truth("var x = ; x.addNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p') === undefined"); truth("var x = ; x.removeNamespace(new Namespace('p', 'u')); x.namespace('p').uri === 'u'"); @@ -1488,7 +1492,12 @@ fn comparing_xml_values_is_charged_to_the_heap() { /// Runs `setup` with no limit, then `action` in the same realm, and returns how `action` ended. fn after_setup(setup: &str, action: &str) -> Result { - let mut r = realm(); + after_setup_with(e4x(), setup, action) +} + +/// [`after_setup`] in a realm with `options`. +fn after_setup_with(options: Options, setup: &str, action: &str) -> Result { + let mut r = Realm::with_options(options, Limits::default()); let interp = r.interp(); let global = interp.global; assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); @@ -1512,6 +1521,135 @@ fn namespace_declarations_and_qname_strings_are_charged_to_the_heap() { assert_eq!(after_setup(long, action), heap); } +#[test] +fn namespace_scopes_are_charged_to_the_heap() { + let heap = Err(RunErrorKind::Limit(LimitKind::Heap)); + let setup = "var x = ; for (var i = 0; i < 2000; i++) x.addNamespace(new Namespace('p' + i, 'u' + i)); \ + var b = x.b[0]; b.c[0].setNamespace(new Namespace('u7'));"; + for (action, ample) in [ + ("b.namespace('p1999').uri", JsValue::String("u1999".into())), + ("b.namespaceDeclarations().length", JsValue::Number(0.0)), + ("b.inScopeNamespaces().length", JsValue::Number(2000.0)), + ("x.xpath('b[namespace::p1999]').length()", JsValue::Number(1.0)), + ("b.xpath('p5:c').length()", JsValue::Number(0.0)), + ("b.xpath(\"*[name() = 'p7:c']\").length()", JsValue::Number(1.0)), + ] { + assert_eq!(after_setup(setup, &format!("squeeze(16384); {action}")), heap, "{action}"); + assert_eq!(after_setup(setup, action), Ok(ample.clone()), "{action}"); + // The scope reads the same bindings after the script has left garbage to collect. + let churn = format!("squeeze(2000000); for (var i = 0; i < 3000; i++) [i]; {action}"); + assert_eq!(after_setup(setup, &churn), Ok(ample), "{action}"); + } +} + +#[test] +fn namespace_edits_read_attributes_in_place_and_charge_their_walk() { + let heap = Err(RunErrorKind::Limit(LimitKind::Heap)); + let wide = "var s = ''); \ + var n = new Namespace('q', 'v'); var m = new Namespace('s', 'w');"; + let deep = "var s = ''; for (var i = 0; i < 2000; i++) s += ''; for (var i = 0; i < 2000; i++) s += ''; \ + var x = new XML(s + ''); var n = new Namespace('q', 'v');"; + let removed = "x.removeNamespace(n); x.namespace('q') === undefined"; + // An element's attributes are read where they are, so 4,000 of them need no room; the walk of a + // deep tree is held against the limit. + for (tree, setup, action, ample, squeezed) in [ + ("wide", wide, "x.addNamespace(m); x.namespace('s').uri", JsValue::String("w".into()), false), + ("wide", wide, removed, JsValue::Bool(true), false), + ("deep", deep, removed, JsValue::Bool(true), true), + ] { + let options = Options { max_depth: 1_000_000, ..e4x() }; + let want = if squeezed { heap.clone() } else { Ok(ample.clone()) }; + assert_eq!(after_setup_with(options.clone(), setup, &format!("squeeze(4096); {action}")), want, "{tree}: {action}"); + assert_eq!(after_setup_with(options, setup, action), Ok(ample), "{tree}: {action}"); + } +} + +#[test] +fn xml_list_methods_charge_their_copies_to_the_heap() { + let heap = Err(RunErrorKind::Limit(LimitKind::Heap)); + let setup = "var x = ; for (var i = 0; i < 4000; i++) x.appendChild(); var l = x.a;"; + for (action, ample) in [ + ("l.child(0).length()", JsValue::Number(0.0)), + ("l.comments().length()", JsValue::Number(0.0)), + ("l.normalize().length()", JsValue::Number(4000.0)), + ] { + assert_eq!(after_setup(setup, &format!("squeeze(4096); {action}")), heap, "{action}"); + assert_eq!(after_setup(setup, action), Ok(ample), "{action}"); + } +} + +/// The least spare heap, found by bisection, at which `action` ends without the heap limit after +/// `setup`. +fn least_spare(setup: &str, action: &str) -> usize { + let (mut lo, mut hi) = (0usize, 1usize << 22); + assert!(after_setup(setup, &format!("squeeze({hi}); {action}")).is_ok(), "{action}"); + while lo < hi { + let mid = lo + (hi - lo) / 2; + match after_setup(setup, &format!("squeeze({mid}); {action}")) { + Ok(_) => hi = mid, + Err(RunErrorKind::Limit(LimitKind::Heap)) => lo = mid + 1, + Err(e) => panic!("{action}: {e:?}"), + } + } + lo +} + +#[test] +fn replace_charges_its_copy_of_the_matches_to_the_heap() { + let setup = "var x = ; for (var i = 0; i < 4000; i++) x.appendChild();"; + let action = "x.replace('a', 'z'); x.children().length()"; + let select = least_spare(setup, "x.a.length()"); + let replace = least_spare(setup, action); + assert!(replace >= select + 4000 * 4, "select {select}, replace {replace}"); + assert_eq!(after_setup(setup, action), Ok(JsValue::Number(1.0))); +} + +#[test] +fn xpath_qualified_names_are_charged_to_the_heap() { + let setup = "var s = 'a'; for (var i = 0; i < 14; i++) s += s; var x = ; var c = x.c[0]; \ + c.setLocalName(s); c.setNamespace(new Namespace('u'));"; + let action = "x.xpath('*[string-length(name()) = 16386]').length()"; + assert_eq!(after_setup(setup, &format!("squeeze(16384); {action}")), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(setup, action), Ok(JsValue::Number(1.0))); +} + +#[test] +fn xml_methods_charge_the_strings_they_flatten() { + let heap = Err(RunErrorKind::Limit(LimitKind::Heap)); + // `s` and `t` are ropes of 32,768 and 49,152 units; `f` is a flat string that starts with `@`. + let setup = "var s = 'a'; for (var i = 0; i < 15; i++) s += s; var t = 'a b'; for (var i = 0; i < 14; i++) t += t; \ + var f = '@' + s; isXMLName(f); var x = ; var c = x.c[0];"; + let mut wrong = Vec::new(); + for (action, ample) in [ + ("x.addNamespace(s); 1", JsValue::Number(1.0)), + ("new Namespace(t, 'u').prefix", JsValue::Undefined), + ("c.setName(s); 1", JsValue::Number(1.0)), + ("isXMLName(s)", JsValue::Bool(true)), + ("x.attribute(s).length()", JsValue::Number(0.0)), + ("x.attribute(f).length()", JsValue::Number(0.0)), + ("x.namespace(s) === undefined", JsValue::Bool(true)), + ("x.propertyIsEnumerable(s)", JsValue::Bool(false)), + ("c.setLocalName(s); 1", JsValue::Number(1.0)), + ] { + let squeezed = after_setup(setup, &format!("squeeze(16384); {action}")); + if squeezed != heap { + wrong.push(format!("{action} with 16384 spare: {squeezed:?}")); + } + assert_eq!(after_setup(setup, action), Ok(ample), "{action}"); + } + // Parsing a rope needs room for its flat copy on top of what parsing the same flat text needs. + let text = "var k = 'x'; for (var i = 0; i < 15; i++) k += k; var s = ''; \ + var g = ''; isXMLName(g);"; + for ctor in ["XML", "XMLList"] { + let rope = least_spare(text, &format!("new {ctor}(s); 1")); + let flat = least_spare(text, &format!("new {ctor}(g); 1")); + if rope < flat + 32768 { + wrong.push(format!("new {ctor} needs {rope} spare for a rope and {flat} for flat text")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + #[test] fn an_interrupt_stops_serialization() { let mut r = realm(); diff --git a/crates/js/src/xpath.rs b/crates/js/src/xpath.rs index 30707e4de0..6ff84f1508 100644 --- a/crates/js/src/xpath.rs +++ b/crates/js/src/xpath.rs @@ -1472,13 +1472,14 @@ impl<'p> Ev<'p> { let xml = key("xml"); let mut at = 0u32; let mut has_xml = false; - for ns in scope_of(interp, Some(*x))? { - let Some(prefix) = ns.prefix else { continue }; + let scope = scope_of(interp, Some(*x))?; + for ns in &scope.list { + let Some(prefix) = ns.prefix.clone() else { continue }; if prefix.is_empty() && ns.uri.is_empty() { continue; } has_xml |= prefix == xml; - self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri })?; + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri.clone() })?; at += 1; } if !has_xml { @@ -1867,7 +1868,7 @@ impl<'p> Ev<'p> { prefix } } - Some(XNode::Node(x)) => node_name(interp, f, x)?, + Some(XNode::Node(x)) => return node_name(interp, f, x), }; Ok(Text::plain(interp, units)) } @@ -2179,7 +2180,7 @@ impl<'p> Ev<'p> { let uri = if units_are(p, "xml") { key(XML_URI) } else { - match scope.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { + match scope.list.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { Some(ns) if !ns.uri.is_empty() => ns.uri.clone(), _ => { let shown = String::from_utf16_lossy(p); @@ -2213,24 +2214,26 @@ impl<'p> Ev<'p> { } } -fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult { +fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult { let node = interp.xml_node(x)?; let (kind, parent) = (node.kind, node.parent); - let Some(name) = node.name.clone() else { return Ok(empty()) }; - match (f, kind) { - (_, XmlKind::Text | XmlKind::Comment) => Ok(empty()), - (Func::NamespaceUri, XmlKind::Pi) => Ok(empty()), - (Func::NamespaceUri, _) => Ok(Rc::from(name_uri(&name))), - (Func::LocalName, _) | (_, XmlKind::Pi) => Ok(name.local), - _ => qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), - } + let Some(name) = node.name.clone() else { return Ok(Text::plain(interp, empty())) }; + let units = match (f, kind) { + (_, XmlKind::Text | XmlKind::Comment) => empty(), + (Func::NamespaceUri, XmlKind::Pi) => empty(), + (Func::NamespaceUri, _) => Rc::from(name_uri(&name)), + (Func::LocalName, _) | (_, XmlKind::Pi) => name.local, + _ => return qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), + }; + Ok(Text::plain(interp, units)) } -/// An element's or attribute's name as a QName, with a prefix declared for its namespace. -fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &XmlName, attribute: bool) -> JsResult { +/// An element's or attribute's name as a QName, with a prefix declared for its namespace. The units +/// of a prefixed name are held against the heap limit while the text lives. +fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &XmlName, attribute: bool) -> JsResult { let uri = name_uri(name); if uri.is_empty() { - return Ok(name.local.clone()); + return Ok(Text::plain(interp, name.local.clone())); } let prefix = if units_are(uri, XML_URI) { key("xml") @@ -2240,17 +2243,18 @@ fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &X _ => { let scope = scope_of(interp, if attribute { parent } else { Some(x) })?; let usable = |ns: &XmlNs| *ns.uri == *uri && ns.prefix.as_ref().is_some_and(|p| !(attribute && p.is_empty())); - scope.into_iter().find(usable).and_then(|ns| ns.prefix).unwrap_or_else(empty) + scope.list.iter().find(|ns| usable(ns)).and_then(|ns| ns.prefix.clone()).unwrap_or_else(empty) } } }; if prefix.is_empty() { - return Ok(name.local.clone()); + return Ok(Text::plain(interp, name.local.clone())); } - let mut units = prefix.to_vec(); - units.push(COLON); - units.extend_from_slice(&name.local); - Ok(Rc::from(units)) + let mut buf = TextBuf::new(interp); + buf.push(interp, &prefix)?; + buf.push(interp, &[COLON])?; + buf.push(interp, &name.local)?; + Ok(buf.finish()) } /// The first index where `needle` occurs in `hay`. Every position tried takes fuel. diff --git a/crates/mcp/src/tests/conventions.rs b/crates/mcp/src/tests/conventions.rs index 0b7352bf4a..3618f9214f 100644 --- a/crates/mcp/src/tests/conventions.rs +++ b/crates/mcp/src/tests/conventions.rs @@ -313,6 +313,84 @@ fn window_captures_take_max_side() { assert!(r["content"][0]["text"].as_str().unwrap().contains("`scale`"), "{r}"); } +/// The path an app from before `ui.screenshot {data}` was sent, with the mode of the folder holding +/// it on unix (`None` elsewhere). +type SeenPaths = std::sync::Arc)>>>; + +/// An app from before `ui.screenshot {data}`: it writes `png` (nothing when `None`) to the `path` +/// it is sent, keeps the path in `seen` and answers `reply`. A capture without a path is answered +/// with its size alone. +struct OldWindow { + png: Option>, + reply: Result, + seen: SeenPaths, +} +impl Backend for OldWindow { + fn call(&mut self, method: &str, p: Value) -> Result { + if method != "ui.screenshot" { + return Err(format!("unexpected {method}")); + } + // Without a path it ignores `data` and sends the size alone. + let Some(path) = p["path"].as_str().map(str::to_owned) else { return Ok(json!({"width": 2, "height": 1})) }; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt as _; + std::path::Path::new(&path).parent().and_then(|d| std::fs::metadata(d).ok()).map(|m| m.permissions().mode() & 0o777) + }; + #[cfg(not(unix))] + let mode = None; + self.seen.lock().unwrap().push((path.clone(), mode)); + if let Some(png) = &self.png { + std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?; + } + self.reply.clone() + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "old window".into() + } +} + +/// An app without `ui.screenshot {data}` writes the window capture to a file in a new folder of its +/// own inside the system's temporary folder (owner-only on unix), never to a fixed name in the +/// shared folder itself. The folder is removed after the capture is read, and also when the app +/// fails after writing it or the file can't be read. +#[test] +fn an_old_app_captures_the_window_in_a_new_private_folder() { + let temp = std::env::temp_dir(); + let png = b"window capture".to_vec(); + let size = json!({"width": 2, "height": 1}); + let mut folders = Vec::new(); + for (written, reply, ok) in [ + (Some(png.clone()), Ok(size.clone()), true), + (Some(png.clone()), Ok(size.clone()), true), + (Some(png.clone()), Err("the capture failed".to_string()), false), + (None, Ok(size.clone()), false), + ] { + let seen = SeenPaths::default(); + let mut s = Server::new(Box::new(OldWindow { png: written, reply, seen: seen.clone() })); + let r = call(&mut s, "screenshot", json!({"window": true})); + assert_eq!(r["isError"], !ok, "{r}"); + if ok { + assert_eq!(vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap(), png, "{r}"); + } + let seen = seen.lock().unwrap().clone(); + let [(path, mode)] = seen.as_slice() else { panic!("one capture: {seen:?}") }; + let dir = std::path::Path::new(path).parent().unwrap().to_path_buf(); + assert_eq!(dir.parent(), Some(temp.as_path()), "{path} is in a folder of its own inside {}", temp.display()); + assert!(!dir.exists(), "{} is removed", dir.display()); + if cfg!(unix) { + assert_eq!(*mode, Some(0o700), "{path}"); + } + folders.push(dir); + } + folders.sort(); + folders.dedup(); + assert_eq!(folders.len(), 4, "each capture has a new folder: {folders:?}"); +} + /// Each tool with another name for the same call names it in its description. #[test] fn alias_tools_name_their_pair() { diff --git a/crates/mcp/src/tests/exportas.rs b/crates/mcp/src/tests/exportas.rs index f46956bab8..4e41002bf0 100644 --- a/crates/mcp/src/tests/exportas.rs +++ b/crates/mcp/src/tests/exportas.rs @@ -40,9 +40,21 @@ fn an_option_no_format_takes_is_an_error() { assert!(e.contains(named), "{method}: {e}"); } } - // One object can still carry several formats' options. + // One object can still carry several formats' options; the warnings name those the file + // doesn't read. let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}})); assert!(!r.is_error, "{r:?}"); + let warnings = text(&r)["warnings"].clone(); + for key in ["quality", "styling"] { + assert!(warnings.as_array().unwrap().contains(&json!(format!("`{key}` was not used: PDF files don't read it"))), "{warnings}"); + } + // `app.export` and `file.export` give the same warning. + let params = json!({"format": "pdf", "quality": 80}); + let note = json!("`quality` was not used: PDF files don't read it"); + let r = h.call("app.export", params.clone()).unwrap(); + assert!(r["warnings"].as_array().unwrap().contains(¬e), "app.export: {r}"); + let r = h.call("engine.execute", json!({"command": "file.export", "params": params})).unwrap(); + assert!(r["warnings"].as_array().unwrap().contains(¬e), "file.export: {r}"); } /// `app.save`, `file.save` and `file.exportForScreens` refuse a key no format takes before they diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index e76b5704ae..39d3f3f8e1 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -437,18 +437,52 @@ pub(super) fn screenshot(b: &mut dyn Backend, a: &Args) -> Result) -> Result { - let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string(); - let r = b.call("ui.screenshot", json!({"path": tmp}))?; - let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?; - // Best effort: a temporary file left behind is harmless. - std::fs::remove_file(&tmp).ok(); + let dir = new_capture_folder(&std::env::temp_dir())?; + let tmp = dir.join("window.png").to_string_lossy().to_string(); + let captured = b.call("ui.screenshot", json!({"path": tmp})).and_then(|r| Ok((std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?, r))); + // Best effort: a folder that can't be removed holds only this capture, and on unix only its + // owner can open it. + let _ = std::fs::remove_dir_all(&dir); + let (png, r) = captured?; let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null}); let png = fit_capture(png, max, &mut info)?; Ok(image_result(&png, info)) } +/// A new folder in `parent` named `vectorcraft-window-` followed by the process id, `-` and a count +/// of the process's captures; on unix only its owner can open it. A name that is already taken, by +/// an earlier process with the same id or by anyone else, is skipped for the next count. +fn new_capture_folder(parent: &std::path::Path) -> Result { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + loop { + let dir = parent.join(format!("vectorcraft-window-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + match private_folder().create(&dir) { + Ok(()) => return Ok(dir), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("{}: {e}", dir.display())), + } + } +} + +/// A builder for folders that only their owner can read, write or open (mode 0o700). +#[cfg(unix)] +fn private_folder() -> std::fs::DirBuilder { + let mut b = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b +} + +/// A builder for folders: a plain one outside unix. +#[cfg(not(unix))] +fn private_folder() -> std::fs::DirBuilder { + std::fs::DirBuilder::new() +} + /// `max_side`: absent, or an integer in 1..4096. pub(super) fn max_side(a: &Args) -> Result, String> { match a.get("max_side") { diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e94164cc15..8836b72ac2 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -195,7 +195,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), - ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ( + "view.snapToPixel", + "Snap to Pixel", + "", + "{} toggle: drawing, moving and scaling land on whole pixels, each while its option in view.snapToPixel.options is on", + ), ( "view.snapToGlyph", "Snap to Glyph", @@ -1253,7 +1258,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", @@ -1472,13 +1477,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.action.record", "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}", + "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}", ), ( "ui.action.stop", "Stop Playing/Recording", "", - "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it or the action ui.action.record named, at the place it named, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( "ui.action.insertStop", @@ -1516,6 +1521,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", ), + ( + "ui.action.moveStep", + "Move Action Step", + "", + "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}", + ), ( "ui.batchDialog", "Batch…", diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index d45ecc7142..74bd95d455 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -5,8 +5,11 @@ // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use std::path::{Path, PathBuf}; +mod common; +use std::path::Path; + +use common::TempFolder; use serde_json::{Value, json}; use vectorcraft_engine::file_access::AutomationRoots; use vectorcraft_mcp::{Headless, Server}; @@ -14,9 +17,10 @@ use vectorcraft_mcp::{Headless, Server}; const SVG: &str = r#""#; /// A folder holding `in` (the read root, with `a.svg`), `out` (the write root), `outside` (with -/// `b.svg`) and `in2`, and a server confined to `in` and `out` (either may be left out). -fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { - let base = vectorcraft_testkit::temp_dir(&format!("roots-{tag}")); +/// `b.svg`) and `in2`, and a server confined to `in` and `out` (either may be left out). The +/// folder is removed when the returned [`TempFolder`] is dropped. +fn confined(tag: &str, read: bool, write: bool) -> (Server, TempFolder) { + let base = TempFolder::new(&format!("roots-{tag}")); for d in ["in", "out", "outside", "in2"] { std::fs::create_dir_all(base.join(d)).unwrap(); } @@ -55,6 +59,29 @@ fn refused(server: &mut Server, name: &str, args: Value, why: &str) { assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}"); } +/// A `TempFolder` is removed with the files and folders in it when dropped, also when the test +/// that holds it panics. +#[test] +fn temp_folders_are_removed_when_dropped() { + let dir = TempFolder::new("roots-drop-check"); + std::fs::create_dir(dir.join("sub")).unwrap(); + std::fs::write(dir.join("sub/a.svg"), SVG).unwrap(); + let path = dir.to_path_buf(); + drop(dir); + assert!(!path.exists(), "{} is removed", path.display()); + + let mut held = None; + let panicked = vectorcraft_testkit::catch_quiet(|| { + let dir = TempFolder::new("roots-panic-check"); + std::fs::write(dir.join("a.svg"), SVG).unwrap(); + held = Some(dir.to_path_buf()); + panic!("the test fails with {} in use", dir.display()); + }); + assert!(panicked.is_err()); + let path = held.unwrap(); + assert!(!path.exists(), "{} is removed after a panic", path.display()); +} + #[test] fn files_open_only_from_inside_the_read_root() { let (mut server, base) = confined("open", true, true); diff --git a/crates/mcp/tests/common/mod.rs b/crates/mcp/tests/common/mod.rs new file mode 100644 index 0000000000..08954ca21e --- /dev/null +++ b/crates/mcp/tests/common/mod.rs @@ -0,0 +1,28 @@ +//! Shared helpers for the MCP integration tests. + +use std::path::{Path, PathBuf}; + +/// A test's own folder, made by [`vectorcraft_testkit::temp_dir`] (with its checks against a +/// folder made by someone else) and removed with everything in it when dropped, also when the +/// test panics. A removal that fails (a file still open) is ignored. Tests that run at the same +/// time use different tags. +pub struct TempFolder(PathBuf); + +impl TempFolder { + pub fn new(tag: &str) -> Self { + Self(vectorcraft_testkit::temp_dir(tag)) + } +} + +impl std::ops::Deref for TempFolder { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempFolder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index f9dc065ff1..b3145a20b9 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -4,6 +4,9 @@ // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod common; + +use common::TempFolder; use proptest::prelude::*; use serde_json::{Value, json}; use vectorcraft_doc::Document; @@ -98,7 +101,7 @@ fn tool_definitions_are_valid() { #[test] fn every_tool_survives_empty_and_junk_arguments() { - let dir = vectorcraft_testkit::temp_dir("mcp-junk"); + let dir = TempFolder::new("mcp-junk"); let safe = dir.join("junk.vectorcraft").to_string_lossy().to_string(); let mut failures = vec![]; for t in tool_definitions() { @@ -169,7 +172,7 @@ proptest! { tail in junk_tail(), tool in prop::sample::select(vec!["open_file", "save_file", "export", "screenshot"]), ) { - let base = vectorcraft_testkit::temp_dir("mcp-confined"); + let base = TempFolder::new("mcp-confined"); let root = base.join("a/b/c/root"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(base.join("outside")).unwrap(); diff --git a/crates/pathops/src/addons/cleanup.rs b/crates/pathops/src/addons/cleanup.rs index edb3489007..75cf45b583 100644 --- a/crates/pathops/src/addons/cleanup.rs +++ b/crates/pathops/src/addons/cleanup.rs @@ -3,7 +3,7 @@ //! nearly closed paths; [`near_point_groups`] finds anchors to snap together, and [`rejoin`] welds //! open pieces whose ends meet back into paths. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use kurbo::{Point, Vec2}; use vectorcraft_geom::{Anchor, AnchorKind, EPS, SubPath}; @@ -23,24 +23,64 @@ fn moved(p: Point, a_p: Point, h: Point) -> Point { /// Remove the corner anchors (no handles, straight segments both sides) where a straight run goes /// on in the same direction: the sine of the angle it turns there is at most `tolerance`. Open ends /// stay, and a subpath keeps two anchors (three when closed) → how many went. +/// +/// Each anchor is tested once, first to last, against the nearest anchors still kept on either +/// side. On a closed subpath, an anchor with none kept before it has the last anchor as its +/// previous neighbor, and the last anchor has the first one kept as its next neighbor. The kept +/// anchors move into place once at the end, so the work grows linearly with the number of anchors. pub fn remove_collinear_anchors(sp: &mut SubPath, tolerance: f64) -> usize { - let mut removed = 0; - let mut i = 0; - while i < sp.anchors.len() && sp.anchors.len() > min_anchors(sp) { - let n = sp.anchors.len(); - let inner = sp.closed || (i > 0 && i + 1 < n); - let (Some(prev), Some(cur), Some(next)) = (sp.anchors.get((i + n - 1) % n), sp.anchors.get(i), sp.anchors.get((i + 1) % n)) else { break }; + let n = sp.anchors.len(); + let min = min_anchors(sp); + if n <= min { + return 0; + } + let mut keep = vec![true; n]; + let (mut left, mut first_kept, mut last_kept) = (n, None, None); + #[cfg(test)] + let mut work = 0; + for i in 0..n { + if left <= min { + break; + } + #[cfg(test)] + { + work += 1; + } + // Every anchor after `i` is still there, the last one included. + let p = last_kept.unwrap_or(n - 1); + let q = if i + 1 < n { i + 1 } else { first_kept.unwrap_or(i) }; + let inner = sp.closed || (last_kept.is_some() && i + 1 < n); + let (Some(prev), Some(cur), Some(next)) = (sp.anchors.get(p), sp.anchors.get(i), sp.anchors.get(q)) else { break }; let straight = !prev.has_out() && !cur.has_in() && !cur.has_out() && !next.has_in(); let (a, b) = (cur.p - prev.p, next.p - cur.p); let len = a.hypot() * b.hypot(); if inner && straight && len > EPS && a.dot(b) > 0.0 && a.cross(b).abs() / len <= tolerance { - sp.anchors.remove(i); - removed += 1; + if let Some(k) = keep.get_mut(i) { + *k = false; + } + left -= 1; } else { - i += 1; + first_kept.get_or_insert(i); + last_kept = Some(i); } } - removed + if left < n { + let mut kept = keep.iter(); + sp.anchors.retain(|_| kept.next().copied().unwrap_or(true)); + #[cfg(test)] + { + work += n; + } + } + #[cfg(test)] + COLLINEAR_WORK.with(|w| w.set(work)); + n - left +} + +#[cfg(test)] +thread_local! { + /// Anchors tested plus anchors moved by the last [`remove_collinear_anchors`] on this thread. + static COLLINEAR_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Merge each anchor lying within `distance` of the one before it (and a closed subpath's last @@ -101,58 +141,203 @@ pub fn close_nearly_closed(sp: &mut SubPath, distance: f64) -> bool { true } +/// The cell of a grid of `size`-wide squares that `p` lies in. +fn grid_cell(p: Point, size: f64) -> (i64, i64) { + ((p.x / size).floor() as i64, (p.y / size).floor() as i64) +} + +/// The cell `(x, y)` and the cells around it, each once. +fn around((x, y): (i64, i64)) -> Vec<(i64, i64)> { + let mut out: Vec<(i64, i64)> = [-1, 0, 1].into_iter().flat_map(|dx| [-1, 0, 1].map(|dy| (x.saturating_add(dx), y.saturating_add(dy)))).collect(); + out.sort_unstable(); + out.dedup(); + out +} + +/// The root of `i`'s set in the union-find forest `parent`. +fn root(parent: &mut [usize], mut i: usize) -> usize { + while let Some(&p) = parent.get(i).filter(|&&p| p != i) { + let up = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(i) { + *slot = up; + } + i = p; + } + i +} + /// Groups of `points` to snap together: points within `distance` of one another (and so on, one /// to the next) that aren't all at the same place, skipping the pairs `linked` names (such as /// neighbouring anchors of one path). Each group lists its points in order; snapping them all to /// the first one makes them coincide. Non-finite points and a distance that isn't a positive number /// find nothing. +/// +/// Two points are compared when their cells of a `distance`-wide grid touch. Each group grows from +/// one point at a time, which looks only at points no group has taken yet, so a cluster of points +/// all within `distance` of one another is grouped with work that grows linearly with its size. pub fn near_point_groups(points: &[Point], distance: f64, linked: impl Fn(usize, usize) -> bool) -> Vec> { if !(distance > 0.0 && distance.is_finite()) { return vec![]; } - let cell = |p: Point| ((p.x / distance).floor() as i64, (p.y / distance).floor() as i64); - let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); - for (i, p) in points.iter().enumerate().filter(|(_, p)| p.is_finite()) { - grid.entry(cell(*p)).or_default().push(i); - } - let mut parent: Vec = (0..points.len()).collect(); - fn root(parent: &mut [usize], mut i: usize) -> usize { - while let Some(&p) = parent.get(i).filter(|&&p| p != i) { - let up = parent.get(p).copied().unwrap_or(p); - if let Some(slot) = parent.get_mut(i) { - *slot = up; - } - i = p; + let mut cells = buckets(points, distance); + let mut near = Near { + distance, + linked, + parent: (0..points.len()).collect(), + taken: points.iter().map(|p| !p.is_finite()).collect(), + queue: vec![], + #[cfg(test)] + work: 0, + }; + for (start, &p) in points.iter().enumerate() { + if near.taken.get(start) != Some(&false) { + continue; } - i - } - for (&(cx, cy), members) in &grid { - for (dx, dy) in [(0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { - let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; - for &i in members { - for &j in others.iter().filter(|&&j| (dx, dy) != (0, 0) || j > i) { - let (Some(a), Some(b)) = (points.get(i), points.get(j)) else { continue }; - let d = a.distance(*b); - if d <= distance && d > 0.0 && !linked(i.min(j), i.max(j)) { - let (ri, rj) = (root(&mut parent, i), root(&mut parent, j)); - if let Some(slot) = parent.get_mut(ri.max(rj)) { - *slot = ri.min(rj); - } - } - } - } + if let Some(t) = near.taken.get_mut(start) { + *t = true; + } + let mut at = Some((start, p)); + while let Some((i, p)) = at { + near.reach(&mut cells, i, p); + at = near.queue.pop().and_then(|j| Some((j, *points.get(j)?))); } } + #[cfg(test)] + NEAR_WORK.with(|w| w.set(near.work)); // Only pairs that aren't at the same place were joined, so no group is all at one place. let mut groups: HashMap> = HashMap::new(); for i in 0..points.len() { - groups.entry(root(&mut parent, i)).or_default().push(i); + groups.entry(root(&mut near.parent, i)).or_default().push(i); } let mut out: Vec> = groups.into_values().filter(|g| g.len() > 1).collect(); out.sort_unstable(); out } +#[cfg(test)] +thread_local! { + /// Buckets, places and points looked at by the last [`near_point_groups`] on this thread. + static NEAR_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The finite points of one cell of a grid that lie in one square a quarter of a cell wide, with +/// the box around them. +struct Bucket { + lo: Point, + hi: Point, + /// Each place (bit for bit) and the points there that no group has taken yet, though some of + /// them may have been taken since. + places: Vec<(Point, Vec)>, +} + +impl Bucket { + /// Whether [`Point::distance`] puts every point here further than `distance` from `p`: one + /// coordinate alone differs by more. The square of a `distance` of 1e-150 or more does not + /// underflow, so the measured distance is at least that difference. + fn beyond(&self, p: Point, distance: f64) -> bool { + distance >= 1e-150 && [self.lo.x - p.x, p.x - self.hi.x, self.lo.y - p.y, p.y - self.hi.y].into_iter().any(|g| g > distance) + } +} + +/// The finite `points` in buckets, by grid cell. +fn buckets(points: &[Point], distance: f64) -> HashMap<(i64, i64), Vec> { + let quarter = distance / 4.0; + let mut cells: HashMap<(i64, i64), Vec> = HashMap::new(); + // Each bucket by its cell and square, and each place by its cell, bucket and coordinates. + let mut bucket_at: HashMap<[i64; 4], usize> = HashMap::new(); + let mut place_at: HashMap<(usize, [i64; 2], [u64; 2]), usize> = HashMap::new(); + for (i, &p) in points.iter().enumerate().filter(|(_, p)| p.is_finite()) { + let c = grid_cell(p, distance); + let list = cells.entry(c).or_default(); + let square = grid_cell(p, quarter); + let b = *bucket_at.entry([c.0, c.1, square.0, square.1]).or_insert_with(|| { + list.push(Bucket { lo: p, hi: p, places: vec![] }); + list.len() - 1 + }); + let Some(bucket) = list.get_mut(b) else { continue }; + bucket.lo = Point::new(bucket.lo.x.min(p.x), bucket.lo.y.min(p.y)); + bucket.hi = Point::new(bucket.hi.x.max(p.x), bucket.hi.y.max(p.y)); + let at = *place_at.entry((b, [c.0, c.1], [p.x.to_bits(), p.y.to_bits()])).or_insert_with(|| { + bucket.places.push((p, vec![])); + bucket.places.len() - 1 + }); + if let Some((_, here)) = bucket.places.get_mut(at) { + here.push(i); + } + } + cells +} + +/// The grouping behind [`near_point_groups`]. +struct Near { + distance: f64, + linked: F, + /// The union-find forest of the groups. + parent: Vec, + /// Whether each point is in a group being grown or grown already (non-finite ones never are). + taken: Vec, + /// Points taken into the group being grown whose own neighbors are still to be looked at. + queue: Vec, + #[cfg(test)] + work: usize, +} + +impl bool> Near { + /// Take into `i`'s group every point no group has taken yet that lies within the distance of + /// `p`, `i`'s place, in the cells around it, at a different place and not linked to `i`. + fn reach(&mut self, cells: &mut HashMap<(i64, i64), Vec>, i: usize, p: Point) { + for c in around(grid_cell(p, self.distance)) { + let Some(buckets) = cells.get_mut(&c) else { continue }; + buckets.retain_mut(|b| { + #[cfg(test)] + { + self.work += 1; + } + if !b.beyond(p, self.distance) { + b.places.retain_mut(|place| { + self.visit(i, p, place); + !place.1.is_empty() + }); + } + !b.places.is_empty() + }); + } + } + + /// Take into `i`'s group the points at `place` that `i` joins, and drop the ones taken. + fn visit(&mut self, i: usize, p: Point, (at, here): &mut (Point, Vec)) { + #[cfg(test)] + { + self.work += 1; + } + let d = p.distance(*at); + if !(d <= self.distance && d > 0.0) { + return; + } + here.retain(|&j| { + if self.taken.get(j) != Some(&false) { + return false; + } + #[cfg(test)] + { + self.work += 1; + } + if (self.linked)(i.min(j), i.max(j)) { + return true; + } + let (ri, rj) = (root(&mut self.parent, i), root(&mut self.parent, j)); + if let Some(slot) = self.parent.get_mut(ri.max(rj)) { + *slot = ri.min(rj); + } + if let Some(t) = self.taken.get_mut(j) { + *t = true; + } + self.queue.push(j); + false + }); + } +} + /// Open pieces welded into one path by [`rejoin`]: `members` index the pieces in the order they /// run along `path`. #[derive(Clone, Debug, PartialEq)] @@ -179,6 +364,12 @@ fn outward(sp: &SubPath, last: bool) -> Option { /// Each junction keeps the earlier piece's anchor and the later piece's out handle. Pieces that are /// closed, have fewer than two anchors or a non-finite coordinate stay out. → the chains of two or /// more pieces, and the single pieces that closed. +/// +/// Two ends are compared when their cells of a `gap`-wide grid touch, and pairs are taken in order +/// of the turn, then the distance, then the order of the ends. Each end looks for its first +/// partner in that order among the ends still free, searching outward from the reverse of its own +/// direction, and two ends that are each other's first partner meet; these are the pairs that +/// taking every pair in that order gives. pub fn rejoin(pieces: &[SubPath], gap: f64) -> Vec { if !(gap >= 0.0 && gap.is_finite()) { return vec![]; @@ -187,7 +378,7 @@ pub fn rejoin(pieces: &[SubPath], gap: f64) -> Vec { !sp.closed && sp.anchors.len() >= 2 && sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) }; // Each usable end: where it is and the way out of its piece there. - let ends: Vec<(End, Point, Vec2)> = pieces + let ends: Vec = pieces .iter() .enumerate() .filter(|(_, sp)| usable(sp)) @@ -197,49 +388,254 @@ pub fn rejoin(pieces: &[SubPath], gap: f64) -> Vec { Some(((i, last), a.p, outward(sp, last)?)) }) .collect(); - let points: Vec = ends.iter().map(|e| e.1).collect(); - // Every pair of ends within the gap, by the turn across the junction. - let mut pairs: Vec<(f64, f64, End, End)> = vec![]; - for (a, b) in candidate_pairs(&points, gap.max(1e-9)) { - let (Some(&(ea, pa, oa)), Some(&(eb, pb, ob))) = (ends.get(a), ends.get(b)) else { continue }; - // A piece's own two ends meet only when closing it leaves a shape (three anchors). - if ea.0 == eb.0 && pieces.get(ea.0).is_none_or(|sp| sp.anchors.len() < 3) { - continue; - } - let d = pa.distance(pb); - if d <= gap { - pairs.push((oa.dot(-ob).clamp(-1.0, 1.0).acos(), d, ea, eb)); - } - } - pairs.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); - let mut link: HashMap = HashMap::new(); - for (_, _, a, b) in pairs { - if !link.contains_key(&a) && !link.contains_key(&b) { - link.insert(a, b); - link.insert(b, a); - } - } + let link = Weld::new(pieces, &ends, gap).link(); chains(pieces, &link) } -/// Pairs (a < b) of `points` that may lie within `gap`: those in neighbouring grid cells. -fn candidate_pairs(points: &[Point], gap: f64) -> Vec<(usize, usize)> { - let cell = |p: Point| ((p.x / gap).floor() as i64, (p.y / gap).floor() as i64); - let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); - for (i, p) in points.iter().enumerate() { - grid.entry(cell(*p)).or_default().push(i); - } - let mut out = vec![]; - for (i, p) in points.iter().enumerate() { - let (cx, cy) = cell(*p); - for dx in -1..=1 { - for dy in -1..=1 { - let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; - out.extend(others.iter().filter(|&&j| j > i).map(|&j| (i, j))); +/// An end, where it lies and the unit direction pointing out of its piece there. +type EndAt = (End, Point, Vec2); + +/// Where a pair of ends (`a` < `b` in the list of ends) comes in the order [`rejoin`] takes pairs: +/// the turn across the junction, the distance between the ends, `a`, the place of `b`'s grid cell +/// among the cells around `a`'s, and `b`. +type Order = (f64, f64, usize, usize, usize); + +/// Whether pair `x` comes before pair `y`. +fn earlier(x: &Order, y: &Order) -> bool { + x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)).then((x.2, x.3, x.4).cmp(&(y.2, y.3, y.4))).is_lt() +} + +/// The place of cell `to` among the cells around `from`, listed by x step and then y step from -1 +/// to 1 (cells past the end of the grid repeat their neighbor; the first place counts). +fn listed_at(from: (i64, i64), to: (i64, i64)) -> usize { + let mut steps = [-1, 0, 1].into_iter().flat_map(|dx| [-1, 0, 1].map(|dy| (from.0.saturating_add(dx), from.1.saturating_add(dy)))); + steps.position(|c| c == to).unwrap_or(9) +} + +/// `angle` as an integer in the same order. +fn angle_key(angle: f64) -> i64 { + let bits = angle.to_bits() as i64; + bits ^ (((bits >> 63) as u64) >> 1) as i64 +} + +/// The angle between the directions at angles `a` and `b`, from 0 to π. +fn apart(a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + d.min(std::f64::consts::TAU - d) +} + +/// How far past the turn of the best pair found so far [`Weld::first_partner`] still looks. The +/// turn [`rejoin`] measures (the arc cosine of a dot product of unit vectors) is within 1e-7 of +/// the angle between the directions, and the angles the search sorts by are within 1e-14 of it. +const TURN_SLACK: f64 = 1e-6; + +/// Ends in one grid cell at one place with one outward direction (bit for bit): every other end +/// pairs with each of them at the same turn and distance. +struct Class { + at: Point, + angle: f64, + /// The ends here no pair has taken yet. + free: BTreeSet, +} + +/// The pairing behind [`rejoin`]. +struct Weld<'a> { + pieces: &'a [SubPath], + ends: &'a [EndAt], + gap: f64, + /// The width of the grid's cells. + size: f64, + classes: Vec, + /// Each end's class. + class_of: Vec, + /// Each grid cell's classes with free ends, by the angle of their direction. + cells: HashMap<(i64, i64), BTreeSet<(i64, usize)>>, + /// Whether each end is still free. + free: Vec, + /// Whether each end has looked for its first partner. Another end's first partner in a class + /// is one of the class's first three free ends, so the ends after those cannot meet next and + /// look only once they move up. + awake: Vec, + /// Each awake end's first partner, and where that pair comes in the order. + first: Vec>, + /// The ends whose first partner each end is (some may have moved on since). + wanted_by: Vec>, + /// Ends to check for a partner whose first partner they are. + todo: Vec, + #[cfg(test)] + work: std::cell::Cell, +} + +impl<'a> Weld<'a> { + fn new(pieces: &'a [SubPath], ends: &'a [EndAt], gap: f64) -> Self { + let size = gap.max(1e-9); + let (mut classes, mut class_of, mut cells) = (vec![], vec![], HashMap::new()); + let mut class_at: HashMap<((i64, i64), [u64; 4]), usize> = HashMap::new(); + for (i, &(_, p, out)) in ends.iter().enumerate() { + let c = grid_cell(p, size); + let k = *class_at.entry((c, [p.x, p.y, out.x, out.y].map(f64::to_bits))).or_insert_with(|| { + let angle = out.y.atan2(out.x); + cells.entry(c).or_insert_with(BTreeSet::new).insert((angle_key(angle), classes.len())); + classes.push(Class { at: p, angle, free: BTreeSet::new() }); + classes.len() - 1 + }); + if let Some(class) = classes.get_mut(k) { + class.free.insert(i); } + class_of.push(k); + } + let n = ends.len(); + Weld { + pieces, + ends, + gap, + size, + classes, + class_of, + cells, + free: vec![true; n], + awake: vec![false; n], + first: vec![None; n], + wanted_by: vec![vec![]; n], + todo: vec![], + #[cfg(test)] + work: std::cell::Cell::new(0), } } - out + + /// The links between the ends that meet: two free ends that are each other's first partner + /// meet, and the ends that wanted either of them look again, until no such pair is left. + fn link(mut self) -> HashMap { + for k in 0..self.classes.len() { + self.wake(k); + } + let mut link: HashMap = HashMap::new(); + while let Some(v) = self.todo.pop() { + let Some(w) = self.partner(v) else { continue }; + if self.free.get(v) != Some(&true) || self.free.get(w) != Some(&true) || self.partner(w) != Some(v) { + continue; + } + let (Some(&(ev, ..)), Some(&(ew, ..))) = (self.ends.get(v), self.ends.get(w)) else { continue }; + link.insert(ev, ew); + link.insert(ew, ev); + self.take(v); + self.take(w); + for e in [v, w] { + if let Some(&k) = self.class_of.get(e) { + self.wake(k); + } + } + let waiting: Vec = [v, w].iter().flat_map(|&e| self.wanted_by.get_mut(e).map(std::mem::take).unwrap_or_default()).collect(); + for x in waiting { + if self.free.get(x) == Some(&true) && matches!(self.partner(x), Some(p) if p == v || p == w) { + self.look(x); + } + } + } + #[cfg(test)] + REJOIN_WORK.with(|w| w.set(self.work.get())); + link + } + + /// End `v`'s first partner, once it has looked. + fn partner(&self, v: usize) -> Option { + self.first.get(v).and_then(|f| f.as_ref()).map(|f| f.1) + } + + /// Have the first three free ends of class `k` that have not looked for a partner look. + fn wake(&mut self, k: usize) { + let Some(class) = self.classes.get(k) else { return }; + let sleeping: Vec = class.free.iter().take(3).copied().filter(|&e| self.awake.get(e) == Some(&false)).collect(); + for e in sleeping { + if let Some(a) = self.awake.get_mut(e) { + *a = true; + } + self.look(e); + } + } + + /// Find free end `e`'s first partner, and queue `e` to check whether that end wants it too. + fn look(&mut self, e: usize) { + let next = self.first_partner(e); + if let Some(list) = next.as_ref().and_then(|f| self.wanted_by.get_mut(f.1)) { + list.push(e); + } + if let Some(f) = self.first.get_mut(e) { + *f = next; + } + self.todo.push(e); + } + + /// End `e` has met another: it is no longer free. + fn take(&mut self, e: usize) { + if let Some(f) = self.free.get_mut(e) { + *f = false; + } + let Some(&k) = self.class_of.get(e) else { return }; + let Some(class) = self.classes.get_mut(k) else { return }; + class.free.remove(&e); + if class.free.is_empty() + && let Some(&(_, p, _)) = self.ends.get(e) + && let Some(set) = self.cells.get_mut(&grid_cell(p, self.size)) + { + set.remove(&(angle_key(class.angle), k)); + } + } + + /// The free end `v` meets first, and where that pair comes in the order: the classes in the + /// cells around `v`'s are looked at by how far their direction is from the reverse of `v`'s, + /// until that is more than [`TURN_SLACK`] past the best turn found. + fn first_partner(&self, v: usize) -> Option<(Order, usize)> { + let &((piece, _), p, out) = self.ends.get(v)?; + let toward = (-out.y).atan2(-out.x); + let split = (angle_key(toward), 0); + let mut best: Option<(Order, usize)> = None; + for c in around(grid_cell(p, self.size)) { + let Some(set) = self.cells.get(&c) else { continue }; + // Both ways round from `toward`; between them they meet every class once. + let mut up = set.range(split..).chain(set.range(..split)).peekable(); + let mut down = set.range(..split).rev().chain(set.range(split..).rev()).peekable(); + let turn = |e: Option<&&(i64, usize)>| e.and_then(|e| self.classes.get(e.1)).map_or(f64::INFINITY, |k| apart(k.angle, toward)); + for _ in 0..set.len() { + let (tu, td) = (turn(up.peek()), turn(down.peek())); + if best.as_ref().is_some_and(|b| tu.min(td) > b.0.0 + TURN_SLACK) { + break; + } + let Some(&(_, k)) = (if tu <= td { up.next() } else { down.next() }) else { break }; + if let Some(pair) = self.pair_with(v, p, piece, k) + && best.as_ref().is_none_or(|b| earlier(&pair.0, &b.0)) + { + best = Some(pair); + } + } + } + best + } + + /// The first free end of class `k` that free end `v` (at `p`, on `piece`) may meet, and where + /// that pair comes in the order. Within a class the pairs differ only in the ends' order, so + /// the end listed first comes first. + fn pair_with(&self, v: usize, p: Point, piece: usize, k: usize) -> Option<(Order, usize)> { + #[cfg(test)] + self.work.set(self.work.get() + 1); + let class = self.classes.get(k)?; + if p.distance(class.at) > self.gap { + return None; + } + // A piece's own two ends meet only when closing it leaves a shape (three anchors). + let short = self.pieces.get(piece).is_none_or(|sp| sp.anchors.len() < 3); + let u = *class.free.iter().find(|&&u| u != v && !(short && self.ends.get(u).is_some_and(|e| e.0.0 == piece)))?; + let (a, b) = (v.min(u), v.max(u)); + let (&(_, pa, oa), &(_, pb, ob)) = (self.ends.get(a)?, self.ends.get(b)?); + let at = listed_at(grid_cell(pa, self.size), grid_cell(pb, self.size)); + Some(((oa.dot(-ob).clamp(-1.0, 1.0).acos(), pa.distance(pb), a, at, b), u)) + } +} + +#[cfg(test)] +thread_local! { + /// Classes of ends looked at by the last [`rejoin`] on this thread. + static REJOIN_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Walk the links between piece ends into chains. diff --git a/crates/pathops/src/addons/cleanup/tests.rs b/crates/pathops/src/addons/cleanup/tests.rs index 3e1c58d42b..d262128234 100644 --- a/crates/pathops/src/addons/cleanup/tests.rs +++ b/crates/pathops/src/addons/cleanup/tests.rs @@ -1,7 +1,12 @@ -use kurbo::{Point, Vec2}; -use vectorcraft_geom::{Anchor, AnchorKind, SubPath}; +use std::collections::HashMap; -use super::{close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles}; +use kurbo::{Point, Vec2}; +use vectorcraft_geom::{Anchor, AnchorKind, EPS, SubPath}; + +use super::{ + End, Rejoined, chains, close_nearly_closed, merge_duplicate_anchors, near_point_groups, outward, rejoin, remove_collinear_anchors, + retract_tiny_handles, root, +}; fn line(pts: &[(f64, f64)], closed: bool) -> SubPath { SubPath::polyline(&pts.iter().map(|&p| p.into()).collect::>(), closed) @@ -30,6 +35,174 @@ fn collinear_anchors_go_and_corners_stay() { assert_eq!(remove_collinear_anchors(&mut bend, 0.005), 1); } +/// The collinear-anchor removal done one `Vec::remove` at a time, testing the anchor that moves +/// into the gap next: the result [`remove_collinear_anchors`] gives. +fn one_at_a_time(sp: &mut SubPath, tolerance: f64) -> usize { + let min = if sp.closed { 3 } else { 2 }; + let mut removed = 0; + let mut i = 0; + while i < sp.anchors.len() && sp.anchors.len() > min { + let n = sp.anchors.len(); + let inner = sp.closed || (i > 0 && i + 1 < n); + let (prev, cur, next) = (sp.anchors[(i + n - 1) % n], sp.anchors[i], sp.anchors[(i + 1) % n]); + let straight = !prev.has_out() && !cur.has_in() && !cur.has_out() && !next.has_in(); + let (a, b) = (cur.p - prev.p, next.p - cur.p); + let len = a.hypot() * b.hypot(); + if inner && straight && len > EPS && a.dot(b) > 0.0 && a.cross(b).abs() / len <= tolerance { + sp.anchors.remove(i); + removed += 1; + } else { + i += 1; + } + } + removed +} + +/// A subpath's closed flag and every anchor's coordinates bit for bit, with its kind. +fn bits(sp: &SubPath) -> (bool, Vec<([u64; 6], AnchorKind)>) { + let anchors = sp.anchors.iter().map(|a| ([a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits), a.kind)); + (sp.closed, anchors.collect()) +} + +/// Check that [`remove_collinear_anchors`] gives `sp` the count and anchors [`one_at_a_time`] does. +fn same_as_one_at_a_time(sp: &SubPath, tolerance: f64) { + let (mut fast, mut slow) = (sp.clone(), sp.clone()); + let got = remove_collinear_anchors(&mut fast, tolerance); + let want = one_at_a_time(&mut slow, tolerance); + assert_eq!(got, want, "count at tolerance {tolerance} for {sp:?}"); + assert_eq!(bits(&fast), bits(&slow), "anchors at tolerance {tolerance} for {sp:?}"); +} + +/// xorshift64: the same numbers on every run. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn pick(&mut self, xs: &[T]) -> T { + xs[(self.next() % xs.len() as u64) as usize] + } +} + +/// A subpath of `n` anchors stepping mostly straight along one direction, with repeated points, +/// small and large bends, reversals, short and long handles and non-finite or huge coordinates. +fn generated(r: &mut Rng, n: usize, closed: bool) -> SubPath { + let dir = r.pick(&[Vec2::new(1.0, 0.0), Vec2::new(0.0, -1.0), Vec2::new(0.6, 0.8), Vec2::new(-1.0, 1.0)]); + let side = Vec2::new(-dir.y, dir.x); + let mut at = Point::new(r.pick(&[0.0, 3.0, -7.5]), r.pick(&[0.0, 2.0])); + let anchors = (0..n) + .map(|_| { + at += dir * r.pick(&[1.0, 1.0, 1.0, 2.0, 0.5, 0.0, -1.0]); + let bend = r.pick(&[0.0, 0.0, 0.0, 0.0, 1e-3, 0.004, 0.005, 0.006, 1.0]); + let mut p = at + side * bend; + match r.next() % 40 { + 0 => p.x = f64::NAN, + 1 => p.y = f64::INFINITY, + 2 => p.x = f64::NEG_INFINITY, + 3 => p = Point::new(p.x * 1e300, p.y * 1e300), + 4 => p = Point::new(1e-300, -1e-300), + _ => {} + } + let handle = |r: &mut Rng| p + Vec2::new(r.pick(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1e-10, 1.0]), 0.0); + let (h_in, h_out) = (handle(r), handle(r)); + Anchor { p, h_in, h_out, kind: r.pick(&[AnchorKind::Corner, AnchorKind::Corner, AnchorKind::Smooth]) } + }) + .collect(); + SubPath::new(anchors, closed) +} + +const TOLERANCES: [f64; 12] = [-1.0, -0.0, 0.0, 1e-9, 0.001, 0.005, 0.5, 1.0, 2.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY]; + +#[test] +fn collinear_anchors_go_as_when_removed_one_at_a_time() { + let mut r = Rng(0x9e37_79b9_7f4a_7c15); + for n in (0..40).chain([64, 100, 257]) { + for _ in 0..40 { + for closed in [false, true] { + let sp = generated(&mut r, n, closed); + for tolerance in TOLERANCES { + same_as_one_at_a_time(&sp, tolerance); + } + } + } + } +} + +#[test] +fn collinear_anchors_keep_their_neighbors_order_and_ends() { + let ring = |pts: &[(f64, f64)]| line(pts, true); + let cases = [ + // A closed run whose first anchor goes: the last anchor's next neighbor is then the second. + ring(&[(5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]), + ring(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0), (5.0, 0.0), (2.0, 4.0)]), + // Every anchor on one line: the path stops at three (closed) or two (open) anchors. + ring(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)]), + line(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)], false), + // The last anchor lies between the first one kept and the one before it. + ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 5.0)]), + // Repeated and zero-length points. + line(&[(0.0, 0.0), (0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 0.0)], false), + ring(&[(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (0.0, 0.0)]), + // Products of huge coordinates overflow; tiny ones underflow. + line(&[(0.0, 0.0), (1e200, 1e200), (2e200, 2e200), (f64::MAX, f64::MAX)], false), + line(&[(0.0, 0.0), (1e-200, 0.0), (2e-200, 0.0)], false), + line(&[(0.0, 0.0), (f64::NAN, 0.0), (2.0, 0.0), (f64::INFINITY, 0.0), (5.0, 0.0)], false), + ]; + for sp in &cases { + for tolerance in TOLERANCES { + same_as_one_at_a_time(sp, tolerance); + } + } + let mut first = cases[0].clone(); + assert_eq!(remove_collinear_anchors(&mut first, 0.0), 1); + assert_eq!(points(&first), [(10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]); + let (mut closed, mut open) = (cases[2].clone(), cases[3].clone()); + assert_eq!((remove_collinear_anchors(&mut closed, 0.0), closed.anchors.len()), (2, 3)); + assert_eq!(remove_collinear_anchors(&mut open, 0.0), 3); + assert_eq!(points(&open), [(0.0, 0.0), (4.0, 0.0)], "open ends stay"); + // A handle on either side of either segment keeps the anchor between them. + for side in 0..4 { + let mut sp = line(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)], false); + let (i, h) = [(0, 1), (1, 0), (1, 1), (2, 0)][side]; + for kind in [AnchorKind::Corner, AnchorKind::Smooth] { + let a = &mut sp.anchors[i]; + a.kind = kind; + *(if h == 0 { &mut a.h_in } else { &mut a.h_out }) = a.p + Vec2::new(0.0, 1.0); + same_as_one_at_a_time(&sp, 0.005); + assert_eq!(remove_collinear_anchors(&mut sp.clone(), 0.005), 0, "handle {h} on anchor {i}"); + } + } +} + +#[test] +fn collinear_anchors_at_the_tolerance_go_and_just_past_it_stay() { + let sp = line(&[(0.0, 0.0), (100.0, 0.1), (200.0, 0.0)], false); + let (a, b) = (sp.anchors[1].p - sp.anchors[0].p, sp.anchors[2].p - sp.anchors[1].p); + let sine = a.cross(b).abs() / (a.hypot() * b.hypot()); + for (tolerance, removed) in [(sine, 1), (sine.next_down(), 0), (sine.next_up(), 1)] { + same_as_one_at_a_time(&sp, tolerance); + assert_eq!(remove_collinear_anchors(&mut sp.clone(), tolerance), removed, "{tolerance}"); + } +} + +#[test] +fn collinear_anchors_go_in_linear_work() { + for (n, closed) in [(10_000, false), (20_000, false), (20_000, true)] { + let pts: Vec = (0..n).map(|i| Point::new(i as f64, 0.0)).collect(); + let mut sp = SubPath::polyline(&pts, closed); + let removed = remove_collinear_anchors(&mut sp, 0.005); + // A closed straight run stops at three anchors; an open one keeps its two ends. + assert_eq!((removed, sp.anchors.len()), if closed { (n - 3, 3) } else { (n - 2, 2) }); + let work = super::COLLINEAR_WORK.with(std::cell::Cell::get); + assert!(work <= 2 * n, "{n} anchors took {work} steps"); + } +} + #[test] fn duplicate_anchors_merge_keeping_the_outer_handles() { let mut sp = SubPath::new( @@ -89,6 +262,244 @@ fn near_points_group_but_linked_and_equal_ones_do_not() { assert!(near_point_groups(&far, 1e-300, |_, _| false).is_empty()); } +/// Nearly Coincident Anchors' grouping comparing every pair of points in touching grid cells: the +/// groups [`near_point_groups`] gives. +fn near_groups_pair_by_pair(points: &[Point], distance: f64, linked: impl Fn(usize, usize) -> bool) -> Vec> { + if !(distance > 0.0 && distance.is_finite()) { + return vec![]; + } + let cell = |p: Point| ((p.x / distance).floor() as i64, (p.y / distance).floor() as i64); + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, p) in points.iter().enumerate().filter(|(_, p)| p.is_finite()) { + grid.entry(cell(*p)).or_default().push(i); + } + let mut parent: Vec = (0..points.len()).collect(); + for (&(cx, cy), members) in &grid { + for (dx, dy) in [(0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; + for &i in members { + for &j in others.iter().filter(|&&j| (dx, dy) != (0, 0) || j > i) { + let d = points[i].distance(points[j]); + if d <= distance && d > 0.0 && !linked(i.min(j), i.max(j)) { + let (ri, rj) = (root(&mut parent, i), root(&mut parent, j)); + parent[ri.max(rj)] = ri.min(rj); + } + } + } + } + } + let mut groups: HashMap> = HashMap::new(); + for i in 0..points.len() { + groups.entry(root(&mut parent, i)).or_default().push(i); + } + let mut out: Vec> = groups.into_values().filter(|g| g.len() > 1).collect(); + out.sort_unstable(); + out +} + +/// Points around a few spots on a lattice, many at the same place, with signed zeros, huge, tiny +/// and non-finite coordinates. +fn generated_points(r: &mut Rng, n: usize, distance: f64) -> Vec { + let spots: Vec = (0..1 + r.next() % 6).map(|_| Point::new((r.next() % 5) as f64 * distance, (r.next() % 5) as f64 * distance)).collect(); + (0..n) + .map(|_| { + let jitter = |r: &mut Rng| r.pick(&[0.0, 0.0, -0.0, 0.1, 0.3, 0.5, 0.7, 0.99, 1.0, 1.01, 2.0, -0.5, -1.0]) * distance; + let at = r.pick(&spots); + let p = Point::new(at.x + jitter(r), at.y + jitter(r)); + match r.next() % 50 { + 0 => Point::new(f64::NAN, p.y), + 1 => Point::new(p.x, f64::INFINITY), + 2 => Point::new(-0.0, -0.0), + 3 => Point::new(p.x * 1e300, p.y), + 4 => Point::new(1e-300, 1e-300), + _ => p, + } + }) + .collect() +} + +#[test] +fn near_points_group_as_when_every_pair_is_compared() { + let mut r = Rng(0x2545_f491_4f6c_dd1d); + let linked: [fn(usize, usize) -> bool; 5] = + [|_, _| false, |a, b| b == a + 1, |a, b| (a * 31 + b * 17) % 5 == 0, |a, b| (a + b) % 2 == 0, |_, _| true]; + for n in (0..30).chain([64, 150, 300]) { + for _ in 0..12 { + for distance in [0.01, 0.5, 1.0, 3.0, 1e-200, 1e300] { + let pts = generated_points(&mut r, n, distance); + for (k, linked) in linked.iter().enumerate() { + let want = near_groups_pair_by_pair(&pts, distance, linked); + assert_eq!(near_point_groups(&pts, distance, linked), want, "linked {k} at {distance} for {pts:?}"); + } + } + } + } +} + +/// A tight cluster of `n` points around `at`, no two at the same place. +fn cluster(at: Point, radius: f64, n: usize) -> Vec { + (0..n).map(|i| at + Vec2::from_angle(i as f64 * 2.399_963) * (radius * ((i + 1) as f64 / n as f64).sqrt())).collect() +} + +#[test] +fn near_points_in_a_dense_cell_group_in_linear_work() { + let n = 4_000; + let neighbours = |a: usize, b: usize| b == a + 1; + let one_cluster = cluster(Point::new(0.005, 0.005), 0.004, n); + let mut two_clusters = cluster(Point::new(0.001, 0.001), 0.0009, n / 2); + two_clusters.extend(cluster(Point::new(0.019, 0.019), 0.0009, n / 2)); + let same_place = vec![Point::new(0.005, 0.005); n]; + let cases = [(&one_cluster, 1, n), (&two_clusters, 2, n / 2), (&same_place, 0, 0)]; + for (pts, count, size) in cases { + let groups = near_point_groups(pts, 0.01, neighbours); + let work = super::NEAR_WORK.with(std::cell::Cell::get); + assert_eq!(groups, near_groups_pair_by_pair(pts, 0.01, neighbours)); + assert_eq!(groups.len(), count); + assert!(groups.iter().all(|g| g.len() == size), "{:?}", groups.iter().map(Vec::len).collect::>()); + assert!(work <= 4 * n, "{n} points took {work} steps"); + } +} + +/// One pair of ends (`a` < `b` in the list of ends) for [`rejoin_sorting_every_pair`]: the turn, +/// the distance, and the two ends. +type Pair = (f64, f64, End, End); + +/// Rejoin listing every pair of ends in touching grid cells and sorting them by turn and then +/// distance: the joins [`rejoin`] makes. +fn rejoin_sorting_every_pair(pieces: &[SubPath], gap: f64) -> Vec { + if !(gap >= 0.0 && gap.is_finite()) { + return vec![]; + } + let usable = |sp: &&SubPath| { + !sp.closed && sp.anchors.len() >= 2 && sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) + }; + let ends: Vec<(End, Point, Vec2)> = pieces + .iter() + .enumerate() + .filter(|(_, sp)| usable(sp)) + .flat_map(|(i, sp)| [(i, sp, false), (i, sp, true)]) + .filter_map(|(i, sp, last)| { + let a = if last { sp.anchors.last() } else { sp.anchors.first() }?; + Some(((i, last), a.p, outward(sp, last)?)) + }) + .collect(); + let size = gap.max(1e-9); + let cell = |p: Point| ((p.x / size).floor() as i64, (p.y / size).floor() as i64); + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, e) in ends.iter().enumerate() { + grid.entry(cell(e.1)).or_default().push(i); + } + let mut pairs: Vec = vec![]; + for (a, &(ea, pa, oa)) in ends.iter().enumerate() { + let (cx, cy) = cell(pa); + for dx in -1..=1 { + for dy in -1..=1 { + let Some(others) = grid.get(&(cx.saturating_add(dx), cy.saturating_add(dy))) else { continue }; + for &b in others.iter().filter(|&&b| b > a) { + let (eb, pb, ob) = ends[b]; + if ea.0 == eb.0 && pieces[ea.0].anchors.len() < 3 { + continue; + } + let d = pa.distance(pb); + if d <= gap { + pairs.push((oa.dot(-ob).clamp(-1.0, 1.0).acos(), d, ea, eb)); + } + } + } + } + } + pairs.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1))); + let mut link: HashMap = HashMap::new(); + for (_, _, a, b) in pairs { + if !link.contains_key(&a) && !link.contains_key(&b) { + link.insert(a, b); + link.insert(b, a); + } + } + chains(pieces, &link) +} + +/// Check that [`rejoin`] joins `pieces` as [`rejoin_sorting_every_pair`] does, bit for bit. +fn same_as_sorting_every_pair(pieces: &[SubPath], gap: f64) { + let (got, want) = (rejoin(pieces, gap), rejoin_sorting_every_pair(pieces, gap)); + let shape = |out: &[Rejoined]| out.iter().map(|j| (j.members.clone(), bits(&j.path))).collect::>(); + assert_eq!(shape(&got), shape(&want), "gap {gap} for {pieces:?}"); +} + +/// Pieces of two to four anchors whose ends sit on or near a few lattice points, often at the +/// same place, some curving, closed or non-finite. +fn generated_pieces(r: &mut Rng, n: usize, gap: f64) -> Vec { + let spot = |r: &mut Rng| { + let near = r.pick(&[0.0, 0.0, 0.0, 0.25, 0.5, 1.0, 1.5]) * gap; + Point::new((r.next() % 3) as f64 + near, (r.next() % 3) as f64 + r.pick(&[0.0, 0.0, near, -near])) + }; + (0..n) + .map(|_| { + let count = 2 + (r.next() % 3) as usize; + let mut pts: Vec = (0..count).map(|_| spot(r)).collect(); + if r.next().is_multiple_of(30) { + pts[0].x = f64::NAN; + } + let mut sp = SubPath::polyline(&pts, r.next().is_multiple_of(15)); + if r.next().is_multiple_of(4) { + let a = &mut sp.anchors[0]; + a.h_out = a.p + r.pick(&[Vec2::new(0.3, 0.1), Vec2::new(0.0, -0.2), Vec2::new(-0.1, -0.1)]); + } + sp + }) + .collect() +} + +#[test] +fn rejoin_joins_as_when_every_pair_is_sorted() { + let mut r = Rng(0xd1b5_4a32_d192_ed03); + for n in (0..25).chain([40, 80, 160]) { + for _ in 0..15 { + for gap in [0.0, 0.05, 0.1, 0.5, 1.0, 3.0] { + same_as_sorting_every_pair(&generated_pieces(&mut r, n, gap), gap); + } + } + } + // Ends meeting from directions that tie: a plus sign, a doubled star and stacked copies. + let plus: Vec = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)].map(|(x, y)| line(&[(0.0, 0.0), (x, y)], false)).to_vec(); + let mut doubled = plus.clone(); + doubled.extend(plus.iter().map(|sp| { + let mut sp = sp.clone(); + sp.reverse(); + sp + })); + let stacked = vec![line(&[(0.0, 0.0), (1.0, 0.0), (2.0, 1.0)], false); 7]; + for pieces in [&plus, &doubled, &stacked] { + for gap in [0.0, 0.1, 5.0] { + same_as_sorting_every_pair(pieces, gap); + } + } +} + +#[test] +fn rejoin_pairs_coincident_ends_in_near_linear_work() { + let n = 2_000; + let ray = |angle: f64| { + let tip = Vec2::from_angle(angle) * 100.0; + line(&[(0.0, 0.0), (tip.x, tip.y)], false) + }; + let star: Vec = (0..n).map(|i| ray(i as f64 * std::f64::consts::TAU / n as f64)).collect(); + let mut r = Rng(0x9e37_79b9_7f4a_7c15); + let scattered: Vec = (0..n).map(|_| ray((r.next() % 1_000_000) as f64 * 1e-6 * std::f64::consts::TAU)).collect(); + let stacked = vec![line(&[(0.0, 0.0), (10.0, 0.0)], false); n]; + for (name, pieces) in [("star", &star), ("scattered", &scattered), ("stacked", &stacked)] { + let out = rejoin(pieces, 0.1); + let work = super::REJOIN_WORK.with(std::cell::Cell::get); + let want = rejoin_sorting_every_pair(pieces, 0.1); + assert_eq!( + out.iter().map(|j| (j.members.clone(), bits(&j.path))).collect::>(), + want.iter().map(|j| (j.members.clone(), bits(&j.path))).collect::>(), + "{name}" + ); + assert!(work <= 20 * n, "{name}: {n} pieces took {work} steps"); + } +} + #[test] fn rejoin_welds_pieces_within_the_gap() { // Three pieces of one zig-zag, the middle one drawn backwards, plus one too far away. diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index a5a3b83863..d4394dfefa 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -30,6 +30,7 @@ craft-pdf = { workspace = true } log = { workspace = true } chrono = { workspace = true } smallvec = "1" +siphasher = "1" weezl = "0.2" serde = { workspace = true } # The XMP packet that File Info is read from (file_info.rs); the version usvg already uses. diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index acb96f1713..77815f93b3 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -463,7 +463,9 @@ impl Exporter<'_> { /// than an image? pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool { let gr = &g.gradient; - !gr.method.is_classic() + // Not one mixed around the hue wheel: its colors between two stops are not tints or + // mixes of the stops' inks, so it stays an image. + gr.method == vectorcraft_color::GradientMethod::Perceptual && gr.kind != GradientKind::Freeform && !(fill && gr.dither) && (self.one_spot_ink(gr).is_some() || self.device_n_inks_only(gr)) @@ -476,7 +478,7 @@ impl Exporter<'_> { /// other gradients, and for an ink too light for lightness to tell its tints apart, which /// [`Self::spot_stops`] then writes with the stops' own tints. fn perceptual_spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { - if g.method.is_classic() { + if g.method != vectorcraft_color::GradientMethod::Perceptual { return None; } let ink = self.one_spot_ink(g)?; @@ -494,7 +496,7 @@ impl Exporter<'_> { let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); if from_stops && self.has_spot_stop(&g.gradient) { self.warn( - "spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", + "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); } } @@ -623,9 +625,9 @@ impl Exporter<'_> { let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b))); let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; if g.gradient.kind == GradientKind::Conic { - self.warn("conic gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images"); + self.warn("conic gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images"); } else if g.gradient.kind == GradientKind::Diamond { - self.warn("diamond gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images"); + self.warn("diamond gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images"); } else if !freeform { self.warn("linear and radial gradients drawn as pixels are exported as images"); } diff --git a/crates/pdf/src/import/actual_text.rs b/crates/pdf/src/import/actual_text.rs index 6d7ddbaf9d..aec147f99e 100644 --- a/crates/pdf/src/import/actual_text.rs +++ b/crates/pdf/src/import/actual_text.rs @@ -104,4 +104,35 @@ impl Builder<'_> { } true } + + /// A Type 3 glyph under an active ActualText span. + pub(super) fn actual_type3_glyph(&mut self, key: u128, at: Placement, look: Look, opacity: f32, advance: f64) -> bool { + self.flush_glyphs(); + self.flush_type3(); + if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text) + && look.fill.is_none() + && line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke) + { + return true; + } + let pending = self.actual.pending.take(); + let content = match pending.as_deref() { + Some(text) if !text.is_empty() => TextContent::Actual(text), + _ => TextContent::Pen, + }; + let place = |line: &mut TextLine| line.push_content(&look, at, opacity, advance, content); + if !self.text.as_mut().is_some_and(place) { + self.flush_text(); + let mut line = TextLine::new(at, opacity); + if !place(&mut line) { + self.actual.pending = pending; + return false; + } + self.text = Some(line); + } + if let Some(line) = &mut self.text { + line.has_type3 = true; + } + true + } } diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 21d862d2fd..c1be8fba84 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -12,6 +12,18 @@ use super::{ Upright, Walked, clipped, contains, fold_stop_opacity, function_paint, gradient_shape, is_rectangle, luminance, mask_spec, shading_gradient, }; +fn is_colour_face(face: &vectorcraft_text::FontFace) -> bool { + use skrifa::Tag; + use skrifa::raw::TableProvider; + let Ok(font) = skrifa::FontRef::new(face.file_data()) else { + return false; + }; + font.colr().is_ok() + || font.table_data(Tag::new(b"SVG ")).is_some() + || font.table_data(Tag::new(b"CBDT")).is_some() + || font.table_data(Tag::new(b"sbix")).is_some() +} + impl<'p> Builder<'p> { pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self { Self { @@ -22,6 +34,7 @@ impl<'p> Builder<'p> { blend: BlendMode::Normal, page: Rect::ZERO, glyphs: None, + type3_run: None, text: None, text_glyphs: vec![], text_as, @@ -102,6 +115,7 @@ impl<'p> Builder<'p> { self.root_groups.clear(); self.blend = BlendMode::Normal; self.glyphs = None; + self.type3_run = None; self.text = None; self.text_glyphs.clear(); self.mask = None; @@ -221,13 +235,48 @@ impl<'p> Builder<'p> { } } - /// Finish the open glyph runs and the art drawn through the current soft mask. + /// Finish the open glyph runs, type 3 runs and the art drawn through the current soft mask. pub(super) fn flush(&mut self) { self.flush_glyphs(); + self.flush_type3(); self.flush_text(); self.flush_masked(); } + /// Finish the open Type 3 font text run as a named group ``. + pub(super) fn flush_type3(&mut self) { + if let Some(run) = self.type3_run.take() + && !run.nodes.is_empty() + { + let Some(left) = self.objects_left.checked_sub(1) else { + self.warn(super::entry::TOO_MANY_OBJECTS); + return; + }; + self.objects_left = left; + let mut g = Node::group(self.id(), run.nodes); + g.name = Some("".to_string()); + self.emit(Arc::new(g), run.group); + } + } + + /// Add nodes from an interpreted Type 3 glyph to the current Type 3 run. + pub(super) fn push_type3_nodes(&mut self, nodes: Vec>) { + if nodes.is_empty() { + return; + } + let group = self.current_group(); + if let Some(run) = &mut self.type3_run { + if run.group != group { + self.flush_type3(); + self.type3_run = Some(super::state::Type3Run { nodes, group }); + } else { + run.nodes.extend(nodes); + } + } else { + self.type3_run = Some(super::state::Type3Run { nodes, group }); + } + } + /// The art drawn through the graphics state's soft mask so far, as one masked object. fn flush_masked(&mut self) { if self.masked.is_empty() { @@ -391,7 +440,7 @@ impl<'p> Builder<'p> { let Some(mut line) = self.text.take() else { return false }; let held = std::mem::take(&mut line.held); // Type the file names itself (ActualText) is in reading order as it stands. - let checked = !glyphs.is_empty() && line.mapped(); + let checked = !glyphs.is_empty() && line.mapped() && !line.has_type3; let mut outlined = false; if let Some((t, opacity, facts)) = line.finish() { if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) { @@ -509,23 +558,26 @@ impl<'p> Builder<'p> { /// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the /// coordinates `page` has. `None` when nested too deeply. - fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option>> { + pub(super) fn nested(&mut self, page: Rect, blend: BlendMode, read: impl FnOnce(&mut Self)) -> Option>> { if self.nested >= MAX_NESTED { return None; } + let outer_type3 = self.type3_run.take(); self.flush(); let saved = Saved { stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]), root_groups: std::mem::take(&mut self.root_groups), - blend: std::mem::replace(&mut self.blend, BlendMode::Normal), + blend: std::mem::replace(&mut self.blend, blend), mask: self.mask.take(), page: std::mem::replace(&mut self.page, page), + type3_run: outer_type3, }; self.nested += 1; read(self); let art = self.close_frames(); self.nested -= 1; - (self.stack, self.root_groups, self.blend, self.mask, self.page) = (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page); + (self.stack, self.root_groups, self.blend, self.mask, self.page, self.type3_run) = + (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.type3_run); Some(art) } @@ -537,7 +589,7 @@ impl<'p> Builder<'p> { } let page = self.page; let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha); - let art = self.nested(page, |b| m.interpret(b)); + let art = self.nested(page, BlendMode::Normal, |b| m.interpret(b)); self.white_images = saved; let spec = match art { Some(art) => { @@ -601,7 +653,7 @@ impl<'p> Builder<'p> { // A swatch is shared with uses outside an alpha mask, and pattern paints keep their // colours there: its images are read as they are. let white = std::mem::take(&mut self.white_images); - let art = self.nested(Rect::ZERO, |b| { + let art = self.nested(Rect::ZERO, BlendMode::Normal, |b| { // A cell whose content can't be decoded is empty. let _ = t.interpret(b, Affine::IDENTITY, stroke); }); @@ -799,6 +851,125 @@ impl<'p> Builder<'p> { self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint }); } + fn type3_font_name(&mut self, key: u128) -> FontInfo { + if let Some(n) = self.font_names.get(&key) { + return n.clone(); + } + let name = self.fonts.get(&key).map(|f| f.name.clone()); + let ps = name.as_deref().map(|n| match n.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(), + _ => n.to_string(), + }); + let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps)); + let n = match (installed, name) { + (Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), face: Some(face) }, + (None, Some(name)) => { + let families = self.families.get_or_insert_with(Families::available); + let (family, style, found) = families.resolve(&name, None, false); + if !found && !family.is_empty() && !self.missing_fonts.contains(&family) { + self.missing_fonts.push(family.clone()); + } + let face = vectorcraft_text::FontDb::global().face(&family, &style); + FontInfo { family, style, face } + } + (None, None) => { + let d = vectorcraft_doc::CharStyle::default(); + FontInfo { family: d.font_family, style: d.font_style, face: None } + } + }; + self.font_names.insert(key, n.clone()); + n + } + + /// Type 3 glyph as live type: added to the line being gathered if it has a ToUnicode mapping. + pub(super) fn type_type3_glyph( + &mut self, + t3: &craft_pdf::interpret::font::Type3Glyph<'_>, + m: Affine, + scale: f64, + paint: Option<&craft_pdf::interpret::Paint<'_>>, + stroke: Option<&StrokeProps>, + overprint: bool, + ) -> bool { + if self.text_as != TextAs::Text || self.nested > 0 { + return false; + } + let t3_key = craft_pdf::interpret::CacheKey::cache_key(t3); + let Some(&(font_key, has_to_unicode)) = self.scan.type3_cache_map.get(&t3_key) else { + return false; + }; + let (is_type3, to_unicode, is_colour) = match self.fonts.get(&font_key) { + Some(f) => (f.is_type3, has_to_unicode || f.to_unicode, f.is_colour), + None => (true, has_to_unicode, false), + }; + let authored = self.actual.active(); + if !is_type3 || (!to_unicode && !authored) || is_colour { + return false; + } + let unicode = t3.as_unicode().filter(|u| !super::cid::replacement(u)); + if !authored && unicode.is_none() { + return false; + } + let text: String = match unicode { + Some(BfString::Char(c)) => c.to_string(), + Some(BfString::String(s)) => s, + None => String::new(), + }; + let Some(at) = Placement::of(m) else { return false }; + if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) { + return false; + } + let width = t3.advance_width().filter(|w| w.is_finite()); + let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); + let info = self.type3_font_name(font_key); + if info.face.as_ref().is_some_and(|f| is_colour_face(f)) { + return false; + } + let (paint, opacity) = match paint { + Some(p) => { + let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true)); + (Some(paint), opacity) + } + None => (None, 1.0), + }; + let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); + let look = Look { + font: font_key, + family: info.family, + style: info.style, + version: None, + size: at.size, + h_scale: at.h_scale, + overprint_fill: stroke.is_none() && overprint, + overprint_stroke: stroke.is_some() && overprint, + fill: if stroke.is_none() { paint } else { None }, + stroke, + }; + if authored { + return self.actual_type3_glyph(font_key, at, look, opacity, advance); + } + let text = visual_glyph_text(text); + let place = |line: &mut TextLine| line.push(&look, at, opacity, advance, &text); + self.flush_glyphs(); + self.flush_type3(); + if let (Some(s), Some(line)) = (&look.stroke, &mut self.text) + && look.fill.is_none() + && line.stroke_last(font_key, at, s.clone(), overprint) + { + return true; + } + if !self.text.as_mut().is_some_and(place) { + self.flush_text(); + let mut line = TextLine::new(at, opacity); + place(&mut line); + self.text = Some(line); + } + if let Some(line) = &mut self.text { + line.has_type3 = true; + } + true + } + /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps /// its outline (no Unicode text, mirrored, not type mode, or in a span whose ActualText is /// empty). No `paint`: invisible text, diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index b39905d6e0..3fcefd7b8e 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -33,6 +33,7 @@ impl<'a> Device<'a> for Builder<'_> { fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) { self.flush_glyphs(); + self.flush_type3(); let walked = self.walked(false); if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) { return; @@ -174,7 +175,7 @@ impl<'a> Device<'a> for Builder<'_> { GlyphDrawMode::Invisible => { let typed = match glyph { Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false), - Glyph::Type3(_) => false, + Glyph::Type3(t3) => self.type_type3_glyph(t3, transform * glyph_transform, mean_scale(transform), None, None, false), }; if !typed { self.warn(INVISIBLE_TEXT_NOTE); @@ -186,6 +187,7 @@ impl<'a> Device<'a> for Builder<'_> { }; match glyph { Glyph::Outline(o) => { + self.flush_type3(); let scale = mean_scale(transform); let overprint = self.glyph_overprint(stroke.is_some()); if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { @@ -216,16 +218,26 @@ impl<'a> Device<'a> for Builder<'_> { if self.nested >= MAX_NESTED { return; } - self.flush(); - self.nested += 1; - t3.interpret(self, transform, glyph_transform, paint); - self.nested -= 1; + let scale = mean_scale(transform); + let overprint = self.glyph_overprint(stroke.is_some()); + if self.type_type3_glyph(t3, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { + return; + } + self.flush_glyphs(); + self.flush_text(); + let art = self.nested(self.page, self.blend, |b| { + t3.interpret(b, transform, glyph_transform, paint); + }); + if let Some(nodes) = art { + self.push_type3_nodes(nodes); + } } } } fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { self.flush_glyphs(); + self.flush_type3(); self.flush_text(); match image { Image::Raster(r) => { diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index b74cbf5dab..0470d641e2 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -62,6 +62,13 @@ pub(super) struct GlyphRun { /// swatch made for it. pub(super) type PatternReadings = (u32, Vec<(Vec, String)>); +/// A run of Type 3 glyph graphics preserved inside a named group. +#[derive(Clone, Debug)] +pub(super) struct Type3Run { + pub(super) nodes: Vec>, + pub(super) group: Option, +} + /// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back. pub(super) struct Saved { pub(super) stack: Vec, @@ -69,6 +76,7 @@ pub(super) struct Saved { pub(super) blend: BlendMode, pub(super) mask: Option>, pub(super) page: Rect, + pub(super) type3_run: Option, } pub(super) struct Builder<'p> { @@ -80,6 +88,7 @@ pub(super) struct Builder<'p> { pub(super) blend: BlendMode, pub(super) page: Rect, pub(super) glyphs: Option, + pub(super) type3_run: Option, pub(super) text: Option, /// The glyphs of the open line of type (`text`) that show, each as the outline it would be /// kept as: what the line becomes when its type wouldn't draw as the file does diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 4f4e35dd3e..b65254eafa 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -13,6 +13,8 @@ use std::collections::{HashMap, HashSet}; +use siphasher::sip128::{Hasher128, SipHasher13}; + use crate::import_color::items; use craft_pdf::interpret::CacheKey; use craft_pdf::syntax::Pdf; @@ -250,6 +252,13 @@ fn pdf_doc_char(c: u8) -> char { mapped.copied().unwrap_or(char::from(c)) } +/// Calculate a 128-bit siphash of a value, matching hayro-interpret's `hash128`. +pub(crate) fn hash128(value: &T) -> u128 { + let mut state = SipHasher13::new(); + value.hash(&mut state); + state.finish128().as_u128() +} + /// One page as the interpreter walks it. #[derive(Default)] pub(crate) struct Scan { @@ -282,6 +291,8 @@ pub(crate) struct Scan { /// fills and of its strokes (indices into [`Self::inks`]), where all of its text shown in an /// ink is shown in the same one. text_inks: Changes<[Option; 2]>, + /// Type 3 glyph cache key -> (font cache key, whether font has /ToUnicode). + pub type3_cache_map: HashMap, } impl Scan { @@ -642,9 +653,58 @@ impl Walker<'_, '_> { if let Some(f) = res.fonts.get::>(key.as_ref()) && let Some(name) = font_name(&f) { - self.fonts.entry(f.cache_key()).or_insert_with(|| { + let is_type3 = f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3"); + let to_unicode = f.get::>(b"ToUnicode").is_some(); + let f_key = f.cache_key(); + const MAX_TYPE3_CACHE_MAP: usize = 1 << 20; + if is_type3 && self.out.type3_cache_map.len().saturating_add(257) <= MAX_TYPE3_CACHE_MAP { + for gid in 0..=256u32 { + let hash = hash128(&(f_key, skrifa::GlyphId::new(gid))); + self.out.type3_cache_map.insert(hash, (f_key, to_unicode)); + } + } + let is_colour = if is_type3 { + let mut colour = false; + let mut seen_streams = HashSet::new(); + let mut decoded_bytes = 0usize; + const MAX_CHARPROC_BYTES: usize = 16 << 20; + if let Some(char_procs) = f.get::>(b"CharProcs") { + for key in char_procs.keys() { + if let Some(stream) = char_procs.get::>(key.as_ref()) { + if !seen_streams.insert(stream.cache_key()) { + continue; + } + if decoded_bytes >= MAX_CHARPROC_BYTES { + colour = true; + break; + } + if let Ok(data) = stream.decoded_within(MAX_CHARPROC_BYTES.saturating_sub(decoded_bytes)) { + decoded_bytes = decoded_bytes.saturating_add(data.len()); + let mut iter = TypedIter::new(data.as_ref()); + while let Some(op) = iter.next() { + match op { + TypedInstruction::ColorGlyph(_) => { + colour = true; + break; + } + TypedInstruction::ShapeGlyph(_) => break, + _ => {} + } + } + if colour { + break; + } + } + } + } + } + colour + } else { + false + }; + self.fonts.entry(f_key).or_insert_with(|| { let embedded = embeds_glyphs(&f); - FontNote { name, embedded, vertical: vertical_metrics(&f, embedded) } + FontNote { name, embedded, vertical: vertical_metrics(&f, embedded), is_type3, to_unicode, is_colour } }); } } @@ -879,6 +939,12 @@ pub(crate) struct FontNote { pub embedded: bool, /// A composite font written vertically: its descendant's metrics. pub vertical: Option, + /// Whether this is a Type 3 font. + pub is_type3: bool, + /// Whether this font specifies a /ToUnicode map. + pub to_unicode: bool, + /// Whether this Type 3 font contains coloured glyph descriptions (d0/ColorGlyph). + pub is_colour: bool, } /// The most CIDs a vertical font's widths are read for (the most a CID-keyed font has). @@ -1020,6 +1086,15 @@ fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) { /// descendant CIDFont's `BaseFont` is taken, the font program's own name (§9.7.4.1); without one, /// the `BaseFont` without the CMap's name on its end. fn font_name(f: &Dict<'_>) -> Option { + if f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") { + if let Some(base) = f.get::>(b"BaseFont") { + return Some(String::from_utf8_lossy(base.as_ref()).into_owned()); + } + if let Some(name) = f.get::>(b"Name") { + return Some(String::from_utf8_lossy(name.as_ref()).into_owned()); + } + return Some("Type3".to_string()); + } let base = String::from_utf8_lossy(f.get::>(b"BaseFont")?.as_ref()).into_owned(); if f.get::>(b"Subtype").is_none_or(|s| s.as_ref() != b"Type0") { return Some(base); diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 200ec93673..9b0122326f 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -218,6 +218,8 @@ pub(crate) struct TextLine { /// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far /// (written right after the line). pub held: Vec, + /// Whether this line holds Type 3 font glyphs. + pub has_type3: bool, } /// Where the file put a glyph: the glyph's index in the line, and its advance in the file's font @@ -266,6 +268,7 @@ impl TextLine { drawn_spaces: vec![], drawn_space: None, held: vec![], + has_type3: false, } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 8caac7b0de..8f9959c77f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,7 +5,7 @@ //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and -//! freeform and Perceptual gradients and dithered gradient fills as images at the raster +//! freeform and Perceptual gradients, gradients mixed around the hue wheel and dithered gradient fills as images at the raster //! effects resolution (clipped to what they paint), embedded images (resampled and //! compressed as the Compression settings say) and text as outlined glyph paths, or as real //! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 710037a7c1..adaaa30c9c 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -217,7 +217,7 @@ choice! { } /// Save PDF settings (the dialog's sections; what a preset stores). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PdfSettings { pub standard: Standard, @@ -237,9 +237,9 @@ pub struct PdfSettings { /// `create_layers` is on). pub include_non_printing: bool, /// Write a page with a side longer than 14,400 pt (200 in) at its size in points, without a - /// `UserUnit`, at every PDF version, in files without a standard (off, and in PDF/A files: - /// PDF 1.6 and later files that aren't PDF/X files write it with a `UserUnit`, and PDF 1.5 - /// files are written as PDF 1.6 for it). + /// `UserUnit`, at every PDF version, in files without a standard (on by default; off, and in + /// PDF/A files: PDF 1.6 and later files that aren't PDF/X files write it with a `UserUnit`, + /// and PDF 1.5 files are written as PDF 1.6 for it). pub long_pages_in_points: bool, pub compression: CompressionSettings, pub marks: MarkSettings, @@ -253,6 +253,29 @@ pub struct PdfSettings { pub flattener_preset: String, } +impl Default for PdfSettings { + fn default() -> Self { + Self { + standard: Standard::default(), + compatibility: Compatibility::default(), + preserve_editing: false, + thumbnails: false, + fast_web_view: false, + view_after_saving: false, + create_layers: false, + include_non_printing: false, + long_pages_in_points: true, + compression: CompressionSettings::default(), + marks: MarkSettings::default(), + bleed: BleedSettings::default(), + output: OutputSettings::default(), + advanced: AdvancedSettings::default(), + security: SecuritySettings::default(), + flattener_preset: String::new(), + } + } +} + /// Resampling and compression of one kind of image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 5b86f2b895..45222bb04d 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -687,6 +687,26 @@ fn dithered_and_perceptual_gradients_of_cmyk_stops_are_written_as_cmyk_images() assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB"); } +/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a +/// gradient of CMYK stops mixed that way is an RGB image, in a linear, radial, conic or diamond +/// gradient. +#[test] +fn hue_wheel_gradients_of_cmyk_stops_are_written_as_rgb_images() { + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + for mode in [ColorMode::Cmyk, ColorMode::Rgb] { + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let stops = vec![GradientStop::new(0.0, Color::cmyk(0.0, 1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::cmyk(1.0, 1.0, 0.0, 0.0))]; + let d = gradient_doc(mode, Gradient { method, ..Gradient::new(kind, stops) }); + let out = export_with_report(&d, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + assert_eq!(w.len(), 1, "{method:?} {mode:?} {kind:?}: one opaque image: {w:?}"); + assert_eq!(w[0].space, "DeviceRGB", "{method:?} {mode:?} {kind:?}"); + assert!(out.warnings.iter().any(|m| m.contains("exported as images")), "{method:?} {mode:?} {kind:?}: {:?}", out.warnings); + } + } + } +} + /// A stop in another model, or linked to a spot swatch, keeps the RGB image. #[test] fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() { diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 655ed43ea4..4693b4c44b 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -105,8 +105,9 @@ fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> { } /// A page with a side longer than ISO 32000-1's 14,400 units is written in larger units (its -/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points. -/// Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep their sizes in points. +/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points, +/// with `longPagesInPoints` off. Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep +/// their sizes in points. #[test] fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() { let mut d = doc(); @@ -117,7 +118,7 @@ fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() small.rect = Rect::new(0.0, 12_000.0, 20_000.0, 12_001.0); d.artboards.push(small); let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.01); - let bytes = pdf(&d, json!({})); + let bytes = pdf(&d, json!({"longPagesInPoints": false})); let pages = units_and_media(&bytes); // 20,000 / 14,400 = 1.3889, rounded up to 1.389: the page is 14,398.85 × 7,199.42 units. assert_eq!(pages[0].0, 1.389, "{pages:?}"); @@ -155,6 +156,12 @@ fn report(d: &Document, set: PdfSettings) -> Result { export_with_report(d, &PdfOptions { settings: set, created: Some(1_791_200_000), ..PdfOptions::uncompressed() }) } +/// The default settings with `long_pages_in_points` off: long pages from PDF 1.6 on get a +/// `UserUnit`. +fn user_units() -> PdfSettings { + PdfSettings { long_pages_in_points: false, ..PdfSettings::default() } +} + /// The numbers that follow `key` in the file's bytes (`/MediaBox [0 0 w h]`, `/UserUnit u`), read /// as text, without the PDF reader: none when the file has no `key`. fn raw_numbers(bytes: &[u8], key: &str, count: usize) -> Vec { @@ -178,13 +185,13 @@ fn long_page_warnings(r: &ExportReport) -> Vec<&String> { r.warnings.iter().filter(|w| w.contains("page limit of 14,400 pt")).collect() } -/// A 20 m page at PDF 1.7 (the default) is written with a `UserUnit` of 3.938, reads back at 20 m -/// from the file's bytes, through the PDF reader and through the import, and the export says how -/// it is written. +/// A 20 m page at PDF 1.7 (the default) with `long_pages_in_points` off is written with a +/// `UserUnit` of 3.938, reads back at 20 m from the file's bytes, through the PDF reader and +/// through the import, and the export says how it is written. #[test] fn a_20_m_page_keeps_its_size_with_a_user_unit_and_the_export_says_so() { let d = sized(TWENTY_M, TWENTY_M / 20.0); - let r = report(&d, PdfSettings::default()).unwrap(); + let r = report(&d, user_units()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.7")); assert_eq!( long_page_warnings(&r), @@ -209,7 +216,7 @@ fn a_20_m_page_keeps_its_size_with_a_user_unit_and_the_export_says_so() { /// Every built-in preset and every PDF version keeps a 20 m page at its size, or refuses it with /// the reason: PDF 1.3 and 1.4 files (PDF/X-1a and PDF/X-3 among them) have no `UserUnit` and /// hold no real number past 32,767, PDF 1.5 is written as PDF 1.6 to have one, and PDF/X-4 pages -/// at PDF 1.5 or 1.6 stay in points with a warning. +/// at PDF 1.5 or 1.6 stay in points with a warning (with `long_pages_in_points` off). #[test] fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { let d = sized(TWENTY_M, TWENTY_M / 20.0); @@ -223,7 +230,7 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { }; for preset in crate::builtin_presets() { let name = preset.name.as_str(); - match report(&d, preset.settings.clone()) { + match report(&d, PdfSettings { long_pages_in_points: false, ..preset.settings.clone() }) { Err(e) if matches!(name, "PDF/X-1a:2001" | "PDF/X-3:2002") => refused(e, name), Err(e) => panic!("{name}: {e}"), Ok(r) if name == "PDF/X-4:2010" => { @@ -241,7 +248,7 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { } } for c in Compatibility::ALL.iter().copied() { - let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let set = PdfSettings { compatibility: c, ..user_units() }; match (c, report(&d, set)) { (Compatibility::Pdf13 | Compatibility::Pdf14, Err(e)) => refused(e, c.label()), (Compatibility::Pdf13 | Compatibility::Pdf14, Ok(_)) => panic!("{} is refused", c.label()), @@ -362,7 +369,7 @@ fn the_error_names_the_long_page_whose_art_needs_a_larger_real() { #[test] fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { const MM: f64 = 72.0 / 25.4; - for set in [PdfSettings::default(), PdfSettings { standard: Standard::PdfA2b, ..PdfSettings::default() }] { + for set in [user_units(), PdfSettings { standard: Standard::PdfA2b, ..PdfSettings::default() }] { let r = report(&sized(TWENTY_M * 5.0, 4.0 * MM), set).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.779]); let media = raw_numbers(&r.bytes, "/MediaBox", 4); @@ -377,11 +384,11 @@ fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { assert!(long_page_warnings(&r).is_empty(), "{:?}", r.warnings); } // A short side of 3 units or more at the long side's unit keeps that unit. - let r = report(&sized(TWENTY_M * 5.0, 60.0), PdfSettings::default()).unwrap(); + let r = report(&sized(TWENTY_M * 5.0, 60.0), user_units()).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); let media = raw_numbers(&r.bytes, "/MediaBox", 4); assert!(media[3] - media[1] >= 3.0, "{media:?}"); - let r = report(&sized(TWENTY_M * 5.0, 2.0), PdfSettings::default()).unwrap(); + let r = report(&sized(TWENTY_M * 5.0, 2.0), user_units()).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); } @@ -389,7 +396,7 @@ fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { /// the Save PDF dialog shows it. #[test] fn a_pdf_1_5_file_written_as_1_6_keeps_rc4_encryption() { - let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..PdfSettings::default() }; + let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..user_units() }; set.security.open_password = "secret".into(); let has = |bytes: &[u8], s: &[u8]| bytes.windows(s.len()).any(|w| w == s); let r = report(&sized(TWENTY_M, 300.0), set).unwrap(); @@ -408,7 +415,7 @@ fn the_long_page_warning_rounds_the_size_up() { /// point longer gets a `UserUnit` of 1.001 and the warning, and stays in points at PDF 1.4. #[test] fn a_page_of_14400_pt_stays_in_points_and_one_point_more_does_not() { - let at = |side: f64, c: Compatibility| report(&sized(side, 300.0), PdfSettings { compatibility: c, ..PdfSettings::default() }); + let at = |side: f64, c: Compatibility| report(&sized(side, 300.0), PdfSettings { compatibility: c, ..user_units() }); for c in [Compatibility::Pdf14, Compatibility::Pdf15, Compatibility::Pdf17] { let r = at(14_400.0, c).unwrap(); assert!(long_page_warnings(&r).is_empty() && !r.warnings.iter().any(|w| w.contains("UserUnit")), "{}: {:?}", c.label(), r.warnings); @@ -455,13 +462,14 @@ fn a_page_within_14400_pt_is_written_as_before_at_every_version() { /// 10 m in points. const TEN_M: f64 = 10.0 / 0.0254 * 72.0; -/// With `long_pages_in_points`, a 10 m page at PDF 1.7 is written at its size in points, with no -/// `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the page keeps -/// its `UserUnit`. +/// With `long_pages_in_points` (on by default), a 10 m page at PDF 1.7 is written at its size in +/// points, with no `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the +/// page keeps its `UserUnit`. #[test] fn long_pages_in_points_writes_a_10_m_page_in_points() { let d = sized(TEN_M, TEN_M / 10.0); - let r = report(&d, PdfSettings { long_pages_in_points: true, ..PdfSettings::default() }).unwrap(); + assert!(settings(json!({})).long_pages_in_points, "on by default"); + let r = report(&d, PdfSettings::default()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.7")); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new()); let media = raw_numbers(&r.bytes, "/MediaBox", 4); @@ -479,13 +487,12 @@ fn long_pages_in_points_writes_a_10_m_page_in_points() { let back = import(&r.bytes).unwrap(); let rect = back.artboards[0].rect; assert!((rect.width() - TEN_M).abs() < 0.01 && (rect.height() - TEN_M / 10.0).abs() < 0.01, "{rect:?}"); - // Off (the default), the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. - let off = report(&d, PdfSettings::default()).unwrap(); + // Off, the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. + let off = report(&d, user_units()).unwrap(); assert_eq!(raw_numbers(&off.bytes, "/UserUnit", 1), [1.969]); assert!( long_page_warnings(&off)[0].ends_with("it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller") ); - assert!(!settings(json!({})).long_pages_in_points, "off by default"); } /// With `long_pages_in_points`, every PDF version writes a 10 m page in points: PDF 1.5 stays @@ -496,7 +503,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ let d = sized(TEN_M, TEN_M / 10.0); let with = |set: &PdfSettings| PdfSettings { long_pages_in_points: true, ..set.clone() }; for c in Compatibility::ALL.iter().copied() { - let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let set = PdfSettings { compatibility: c, ..user_units() }; let r = report(&d, with(&set)).unwrap(); assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); @@ -520,7 +527,7 @@ fn pdfa2b_long_page_stays_within_14400_units() { let d = sized(TEN_M, TEN_M / 10.0); for s in Standard::ALL.iter().copied().filter(|s| *s != Standard::None && !s.is_pdfx()) { for c in Compatibility::ALL.iter().copied().filter(|c| s.allows(*c)) { - let off = PdfSettings { standard: s, compatibility: c, ..PdfSettings::default() }; + let off = PdfSettings { standard: s, compatibility: c, ..user_units() }; let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; let (on, off) = (report(&d, on).unwrap(), report(&d, off).unwrap()); assert_eq!(on.bytes, off.bytes, "{} {}", s.label(), c.label()); @@ -540,7 +547,7 @@ fn pdfa2b_long_page_stays_within_14400_units() { fn long_pages_in_points_leaves_a_page_within_14400_pt_as_it_is() { let d = sized(14_400.0, 300.0); for c in Compatibility::ALL.iter().copied() { - let off = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let off = PdfSettings { compatibility: c, ..user_units() }; let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; let (off, on) = (report(&d, off).unwrap(), report(&d, on).unwrap()); assert_eq!(on.bytes, off.bytes, "{}", c.label()); diff --git a/crates/pdf/src/tests/text_placement.rs b/crates/pdf/src/tests/text_placement.rs index f275def4dd..417a88e5b9 100644 --- a/crates/pdf/src/tests/text_placement.rs +++ b/crates/pdf/src/tests/text_placement.rs @@ -112,6 +112,25 @@ fn type3(first: usize, upm: f64) -> Vec { vec![font, a, b] } +/// A Type 3 font with a ToUnicode CMap. +fn type3_with_to_unicode(first: usize, upm: f64) -> Vec { + let s = 1.0 / upm; + let (h, w) = (upm * 0.5, upm); + let a = stream("", &format!("{h} 0 0 0 {h} {upm} d1 0 0 {} {} re f", h * 0.8, upm * 0.7)); + let b = stream("", &format!("{w} 0 0 0 {w} {upm} d1 0 0 {} {} re f", w * 0.9, upm * 0.7)); + let to_u = stream( + "", + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /T3-UCS def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 2 beginbfchar\n<61> <0061>\n<62> <0062>\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + ); + let font = format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 {w} {w}] /FontMatrix [{s} 0 0 {s} 0 0] /CharProcs << /a {} 0 R /b {} 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [{h} {w}] /ToUnicode {} 0 R /Resources << >> >>", + first + 1, + first + 2, + first + 3 + ); + vec![font, a, b, to_u] +} + /// A 300 × 200 pt page drawing `content`, plus `entries` in the page dictionary, with fonts: /// - `/F1`: Source Sans 3, embedded, a simple TrueType font; /// - `/F4`: Source Sans 3 as a Type 0 font, Identity-H (two-byte glyph ids, see [`gids`]); @@ -146,9 +165,13 @@ fn sized_page(w: f64, h: f64, content: &str, entries: &str) -> Vec { let t3 = n + objs.len(); objs.extend(type3(t3, 1000.0)); objs.extend(type3(t3 + 3, 100.0)); + let t3u = t3 + 6; + objs.extend(type3_with_to_unicode(t3u, 1000.0)); + let t30u = t3u + 4; + objs.extend(type3_with_to_unicode(t30u, 100.0)); let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); let resources = format!( - "/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>", + "/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /T3U {t3u} 0 R /T30U {t30u} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>", n + 2, n + 5, n + 6, @@ -416,7 +439,16 @@ fn vertical_glyphs_narrower_than_an_em_stay_on_their_column() { fn type3_glyphs_follow_the_font_matrix() { for font in ["T3", "T30"] { let d = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text); - assert!(texts(&d).is_empty(), "Type 3 text keeps its outlines"); + assert!(texts(&d).is_empty(), "Type 3 text without ToUnicode keeps its outlines"); + let mut groups = 0; + d.walk(|n| { + if let NodeKind::Group { .. } = &n.kind + && n.name.as_deref() == Some("") + { + groups += 1; + } + }); + assert_eq!(groups, 1, "{font}: Type 3 text run must be preserved inside named group "); let mut boxes = contours(&d); boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0)); // `a` at x 20 (0.4 em = 8 pt wide), `b` after its half-em advance; 0.7 em = 14 pt tall, @@ -429,6 +461,60 @@ fn type3_glyphs_follow_the_font_matrix() { } } +/// Type 3 fonts with ToUnicode import as live text in Text mode, and as named groups in Paths mode. +/// Tests both 1000 UPM (/T3U) and 100 UPM (/T30U) to verify FontMatrix scaling. +#[test] +fn type3_font_with_to_unicode_imports_as_live_text() { + for font in ["T3U", "T30U"] { + let d_text = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text); + let t = texts(&d_text); + assert_eq!(t.len(), 1, "{font}: Type 3 with ToUnicode should import as live text"); + assert_eq!(t[0].plain_text(), "ab"); + let run = &t[0].runs[0]; + assert!((run.style.size - 20.0).abs() < 1e-3, "{font}: font size should be 20 pt, got {}", run.style.size); + + let d_paths = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Outlines); + assert!(texts(&d_paths).is_empty(), "In Outlines mode, Type 3 text should be outlines"); + let mut groups = 0; + d_paths.walk(|n| { + if let NodeKind::Group { .. } = &n.kind + && n.name.as_deref() == Some("") + { + groups += 1; + } + }); + assert_eq!(groups, 1, "In Paths mode, Type 3 text should be grouped in "); + } +} + +/// Invisible Type 3 text (mode 3) with ToUnicode imports as searchable text. +#[test] +fn type3_invisible_with_to_unicode_imports_as_searchable_text() { + let d = open_as(&page("BT /T3U 20 Tf 3 Tr 20 50 Td (ab) Tj ET", ""), TextAs::Text); + let t = texts(&d); + assert_eq!(t.len(), 1, "Invisible Type 3 text with ToUnicode imports as searchable text"); + assert_eq!(t[0].plain_text(), "ab"); + for r in &t[0].runs { + assert!(r.style.fill.is_none(), "Invisible text has no fill: {:?}", r.style.fill); + assert!(!r.style.has_stroke(), "Invisible text has no stroke"); + } +} + +/// Type 3 text with ActualText replacement honors authored text or suppression. +#[test] +fn type3_with_actual_text() { + // Authored replacement "XYZ" over Type 3 glyphs (ab) + let d = open_as(&page("/Span << /ActualText (XYZ) >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text); + let t = texts(&d); + assert_eq!(t.len(), 1, "Type 3 with ActualText should import as live text"); + assert_eq!(t[0].plain_text(), "XYZ"); + + // Empty ActualText suppresses mapped text + let d_empty = open_as(&page("/Span << /ActualText () >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text); + let t_empty = texts(&d_empty); + assert!(t_empty.is_empty(), "Empty ActualText should suppress text"); +} + /// A font that isn't embedded or installed is set in a stand-in: the line still starts where /// the file starts it, on its baseline, and is as long as the file's. #[test] diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0beabd34cb..936d5d9e53 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -127,11 +127,31 @@ const NEEDLE_AT: usize = 0x8000; /// its input unchanged otherwise. A live effect made from it draws one shape or the other, so a /// test sees from the geometry whether the host handed the plug-in something. pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> String { + given_or(manifest, given, needle, output, None) +} + +/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and +/// `otherwise` when it does not. +pub fn when_given_else(manifest: &str, given: Given, needle: &str, output: &str, otherwise: &str) -> String { + given_or(manifest, given, needle, output, Some(otherwise)) +} + +/// [`when_given`], returning `otherwise` in place of the input when it is given. +fn given_or(manifest: &str, given: Given, needle: &str, output: &str, otherwise: Option<&str>) -> String { let (src, len) = match given { Given::Input => ("$in", "$il"), Given::Params => ("$pp", "$pl"), }; let n = needle.len(); + // `otherwise` sits right after `output`. + let other_at = OUTPUT_AT + output.len(); + let (none, other) = match otherwise { + Some(o) => ( + format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {other_at}))", o.len()), + format!(r#" (data (i32.const {other_at}) "{}")"#, string(o)), + ), + None => (ECHO.to_string(), String::new()), + }; // For each place `$i` the needle fits, compare it byte by byte (`$j`). let run = format!( r#"(local $i i32) (local $j i32) @@ -149,12 +169,13 @@ pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> S (br $cmp))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $at))) - (return {ECHO})) + (return {none})) (i64.or (i64.shl (i64.const {out}) (i64.const 32)) (i64.const {OUTPUT_AT}))"#, out = output.len(), ); - let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}")"#, string(needle), string(output)); - module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) + let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}"){other}"#, string(needle), string(output)); + let end = other_at + otherwise.map_or(0, str::len); + module(manifest, &run, &extra, end.div_ceil(0x1_0000) as u32 + 1) } /// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs). diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index 29c502cbdd..bedf456f11 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -529,9 +529,9 @@ impl Writer<'_> { self.warn(if freeform { "freeform gradients are written as images clipped to their shapes" } else if conic { - "conic gradients with translucent stops, dither or the Perceptual method are written as images clipped to their shapes" + "conic gradients with translucent stops, dither or a method other than Classic are written as images clipped to their shapes" } else if g.gradient.kind == GradientKind::Diamond { - "diamond gradients with translucent stops, dither, the Perceptual method or no extent are written as images clipped to their shapes" + "diamond gradients with translucent stops, dither, a method other than Classic or no extent are written as images clipped to their shapes" } else { "linear and radial gradients drawn as pixels are written as images clipped to their shapes" }); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index b7013073d9..7e5d220fbb 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -47,6 +47,10 @@ //! rasterised in SVG //! (). //! On characters they are written as linear gradients through stops on the perceptual curve. +//! * **Gradients mixed around the hue wheel** (Hue Clockwise and Hue Counterclockwise, +//! VectorCraft's own methods) fill or stroke objects as an `` of the gradient too, at the +//! document's raster effects resolution. On characters they are written as gradients through +//! stops on their way around the wheel. //! * **Symbol instances** the shared `` would paint differently from the canvas (stained, //! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding //! pattern paints or unlinked masks, which stay on the page) are written as their own art. diff --git a/crates/svg/tests/it/raster_paints.rs b/crates/svg/tests/it/raster_paints.rs index 2ee1dd3bb5..a6bd69fc5a 100644 --- a/crates/svg/tests/it/raster_paints.rs +++ b/crates/svg/tests/it/raster_paints.rs @@ -214,6 +214,39 @@ fn perceptual_gradients_are_images_and_classic_ones_stay_vector() { assert!(!svg.contains(" std::path::PathBuf { dir } +/// A test's own temporary directory, removed with everything in it when dropped, also when the +/// test panics. [`temp_dir`] makes it, with the same checks, under a tag no other `TempDir` in this +/// process uses, so tests running alongside never share one. A removal that fails (a file still +/// held open) is ignored; the next process with the same id empties the directory before use. +pub struct TempDir(std::path::PathBuf); + +impl TempDir { + /// A new empty directory `vectorcraft-testkit---` in the system temporary + /// folder, where `n` counts the `TempDir`s made in this process. + pub fn new(tag: &str) -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Self(temp_dir(&format!("{tag}-{n}"))) + } +} + +impl std::ops::Deref for TempDir { + type Target = std::path::PathBuf; + fn deref(&self) -> &std::path::PathBuf { + &self.0 + } +} + +impl AsRef for TempDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// How many shapes an SVG draws: its `` elements and the basic shapes SVG export writes /// simple shapes as (``, ``, ``, ``, ``, ``). pub fn svg_shapes(svg: &str) -> usize { @@ -134,4 +169,40 @@ mod tests { assert!(super::temp_dir(tag).join("mine.txt").is_file(), "a later call keeps this process's files"); std::fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn a_test_dir_is_removed_with_its_contents_when_dropped() { + let dir = super::TempDir::new("dropped"); + let path = dir.to_path_buf(); + std::fs::create_dir_all(path.join("nested")).unwrap(); + std::fs::write(path.join("nested").join("a.txt"), b"a").unwrap(); + drop(dir); + assert!(!path.exists(), "{} is left", path.display()); + } + + #[test] + fn a_test_dir_is_removed_when_its_test_panics() { + let mut path = None; + let r = super::catch_quiet::<()>(|| { + let dir = super::TempDir::new("panicked"); + std::fs::write(dir.join("a.txt"), b"a").unwrap(); + path = Some(dir.to_path_buf()); + panic!("the test fails"); + }); + assert_eq!(r.err().as_deref(), Some("the test fails")); + let path = path.unwrap(); + assert!(!path.exists(), "{} is left", path.display()); + } + + #[test] + fn test_dirs_with_one_tag_are_separate_and_private() { + let (a, b) = (super::TempDir::new("shared-tag"), super::TempDir::new("shared-tag")); + assert_ne!(*a, *b); + std::fs::write(a.join("a.txt"), b"a").unwrap(); + assert_eq!(std::fs::read_dir(&*b).unwrap().count(), 0, "one test's files are not in another's directory"); + #[cfg(unix)] + assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&*a).unwrap().permissions()) & 0o777, 0o700); + drop(a); + assert!(b.is_dir(), "dropping one leaves the other"); + } } diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 9303d4c0fb..6272e54d71 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -73,6 +73,52 @@ fn poly_intervals(polys: &[Vec], y: f64) -> Vec<(f64, f64)> { xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect() } +/// The part of the horizontal line at `y` closer than `r` to the segment `a`–`b`: the line's +/// intersection with the segment's two end disks and the band between them, which together make +/// a convex shape, so the result is one interval. +fn near_segment(a: Point, b: Point, y: f64, r: f64) -> Option<(f64, f64)> { + if y <= a.y.min(b.y) - r || y >= a.y.max(b.y) + r { + return None; + } + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for p in [a, b] { + let h = r * r - (y - p.y) * (y - p.y); + if h > 0.0 { + lo = lo.min(p.x - h.sqrt()); + hi = hi.max(p.x + h.sqrt()); + } + } + let d = b - a; + let len = d.hypot(); + if len > 0.0 { + let n = kurbo::Vec2::new(-d.y, d.x) * (r / len); + let band = [a + n, b + n, b - n, a - n]; + for i in 0..4 { + let (p, q) = (band[i], band[(i + 1) % 4]); + if (p.y <= y) != (q.y <= y) { + let x = p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x); + lo = lo.min(x); + hi = hi.max(x); + } + } + } + (hi > lo).then_some((lo, hi)) +} + +/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`, less the points +/// closer than `inset` to any edge of them (holes included), measured from the edge itself. +fn inset_intervals(polys: &[Vec], y: f64, inset: f64) -> Vec<(f64, f64)> { + let iv = poly_intervals(polys, y); + if inset <= 0.0 || iv.is_empty() { + return iv; + } + if !inset.is_finite() { + return vec![]; + } + let near = polys.iter().flat_map(|p| (0..p.len()).filter_map(move |i| near_segment(p[i], p[(i + 1) % p.len()], y, inset))); + subtract_intervals(&iv, &union_intervals(near.collect())) +} + /// Union of sorted-or-not intervals. fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { v.sort_by(|a, b| a.0.total_cmp(&b.0)); @@ -168,12 +214,22 @@ impl Region { /// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or /// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally. + /// On a shaped frame the intervals keep the inset from the frame's edges, measured from the + /// edge itself. Along the line, the text also keeps the inset beside each wrap object (outside + /// it, or inside it for Invert Wrap); above and below a wrap object only its offset applies. fn intervals(&self, y: f64) -> Vec<(f64, f64)> { - let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) }; + let mut iv = + if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { inset_intervals(&self.polys, y, self.inset) }; for (polys, off, invert) in self.wraps.iter() { let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] }; let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect()); - iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) }; + iv = if *invert { + let w: Vec<_> = w.into_iter().map(|(a, b)| (a + self.inset, b - self.inset)).filter(|(a, b)| b > a).collect(); + intersect_intervals(&iv, &w) + } else { + let w = union_intervals(w.into_iter().map(|(a, b)| (a - self.inset, b + self.inset)).collect()); + subtract_intervals(&iv, &w) + }; } iv } @@ -186,8 +242,10 @@ impl Region { /// Every horizontal span inside the frame (and the cell) over the band `top..bottom`, left to /// right (text wraps on both sides of an object). pub(super) fn spans(&self, top: f64, bottom: f64) -> Vec<(f64, f64)> { + // The inset from the cell's edges; `intervals` already keeps it from the frame's edges and + // beside the wrap objects. let clip = |(a, b): (f64, f64)| { - let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); + let (a, b) = (a.max(self.cell.x0 + self.inset), b.min(self.cell.x1 - self.inset)); (b > a).then_some((a, b)) }; let plain = self.polys.is_empty() || self.rect; @@ -238,3 +296,100 @@ fn the_cells_of_a_frame_share_its_polygons_and_the_wrap_shapes() { assert!(!first.polys.is_empty() && !first.wraps.is_empty()); assert!(cells.iter().all(|c| c.polys.as_ptr() == first.polys.as_ptr() && c.wraps.as_ptr() == first.wraps.as_ptr())); } + +/// A path with one closed subpath through each list of points. +#[cfg(test)] +fn closed(subpaths: &[&[(f64, f64)]]) -> BezPath { + let mut path = BezPath::new(); + for pts in subpaths { + path.move_to(pts[0]); + for &p in &pts[1..] { + path.line_to(p); + } + path.close_path(); + } + path +} + +/// The spans of every cell of `path` over the band `top..bottom`. +#[cfg(test)] +fn cell_spans(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape], top: f64, bottom: f64) -> Vec> { + Region::cells(path, opts, wrap).iter().map(|c| c.spans(top, bottom)).collect() +} + +#[cfg(test)] +fn assert_spans(got: &[(f64, f64)], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len(), "{got:?} != {want:?}"); + for (g, w) in got.iter().zip(want) { + assert!((g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6, "{got:?} != {want:?}"); + } +} + +/// On a triangle, the inset keeps the line 10 pt from the sloped edge, measured from the edge +/// itself (x + y <= 100 - 10√2 at the band's bottom), not 10 pt along the line. +#[cfg(test)] +#[test] +fn inset_on_a_shaped_frame_is_measured_from_its_sloped_edge() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + let inset = |inset| cell_spans(&triangle, &LayoutOptions { inset, ..LayoutOptions::default() }, &[], 40.0, 60.0); + assert_spans(&inset(10.0)[0], &[(10.0, 40.0 - 10.0 * std::f64::consts::SQRT_2)]); + assert_spans(&inset(0.0)[0], &[(0.0, 40.0)]); +} + +/// A rectangular frame keeps its inset from each side, and its first line starts the inset below +/// its top. +#[cfg(test)] +#[test] +fn a_rectangular_frame_keeps_its_inset_from_each_side() { + let cells = Region::cells(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1), &LayoutOptions { inset: 10.0, ..LayoutOptions::default() }, &[]); + assert!(cells[0].rect); + assert_eq!((cells[0].top(), cells[0].bottom()), (10.0, 90.0)); + assert_spans(&cells[0].spans(40.0, 60.0), &[(10.0, 90.0)]); +} + +/// The inset keeps text clear of a hole's sloped edges too. +#[cfg(test)] +#[test] +fn inset_keeps_text_clear_of_a_hole_s_sloped_edges() { + let square = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + let diamond = [(50.0, 30.0), (70.0, 50.0), (50.0, 70.0), (30.0, 50.0)]; + let spans = cell_spans(&closed(&[&square, &diamond]), &LayoutOptions { inset: 5.0, ..LayoutOptions::default() }, &[], 40.0, 44.0); + let clear = 5.0 * std::f64::consts::SQRT_2; + assert_spans(&spans[0], &[(5.0, 36.0 - clear), (64.0 + clear, 95.0)]); +} + +/// In a frame of columns, the inset is kept once from a column's inner edge and once from the +/// frame's sloped edge. +#[cfg(test)] +#[test] +fn inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + let spans = cell_spans(&triangle, &LayoutOptions { inset: 10.0, columns: 2, gutter: 0.0, ..LayoutOptions::default() }, &[], 10.0, 20.0); + assert_spans(&spans[0], &[(10.0, 40.0)]); + assert_spans(&spans[1], &[(60.0, 80.0 - 10.0 * std::f64::consts::SQRT_2)]); +} + +/// An inset wider than a shaped frame leaves no room for text, whatever its size. +#[cfg(test)] +#[test] +fn an_inset_wider_than_a_shaped_frame_leaves_no_room() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + for inset in [40.0, 1e300, f64::INFINITY] { + let cells = Region::cells(&triangle, &LayoutOptions { inset, ..LayoutOptions::default() }, &[]); + assert!(cells[0].spans(40.0, 60.0).is_empty() && cells[0].span(40.0, 60.0).is_none(), "inset {inset}"); + } +} + +/// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, +/// inside it. +#[cfg(test)] +#[test] +fn text_keeps_the_inset_beside_a_wrap_object_along_the_line() { + let frame = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1); + let shape = PathData::from_bezpath(&Rect::new(40.0, 40.0, 60.0, 60.0).to_path(0.1)); + let opts = LayoutOptions { inset: 5.0, ..LayoutOptions::default() }; + for (invert, want) in [(false, vec![(5.0, 35.0), (65.0, 95.0)]), (true, vec![(45.0, 55.0)])] { + let wrap = vectorcraft_doc::WrapShape { path: shape.clone(), wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert } }; + assert_spans(&cell_spans(&frame, &opts, &[wrap], 45.0, 55.0)[0], &want); + } +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index bab5849c1a..309aa77409 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -23,7 +23,8 @@ pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; mod shadow; pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW}; -use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv}; +use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveDeriv}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2}; /// Resource limits shared by desktop, CLI and browser evaluation. const MAX_PROFILE_POINTS: usize = 16_384; @@ -104,6 +105,10 @@ pub struct Revolve { /// Samples for each curved profile segment, from [`revolve_curve_samples`] for the surfaces of /// one object. `None` plans them from the profile alone. pub curve_samples: Option, + /// Graphics are mapped onto the side: its faces carry a [`SurfaceCell`], and a closed profile + /// that does not touch the axis takes an anchor where its side is cut. Off by default, which + /// leaves the faces as they are without graphics. + pub map_surface: bool, } /// The default light's Rotation and Height: the light of the earlier default direction (45° to the @@ -133,6 +138,7 @@ impl Default for Revolve { smooth_shading: true, cap_ends: true, curve_samples: None, + map_surface: false, } } } @@ -362,6 +368,235 @@ pub struct Face { pub cap: bool, /// Bounded radial strips for a body face joining two curved endpoint rings. pub visibility_pieces: Option>>, + /// For a face of the swept side (not an end cap): where its corners lie on the side surface, + /// for a graphic mapped onto it. + pub surface: Option, +} + +/// Where a face of Revolve's swept side lies on the side surface, unrolled flat: x runs around the +/// axis and y along the profile, in points. x is the angle from the middle of the surface times the +/// point's own distance from the axis, so a graphic keeps its size at every distance and wraps +/// farther around the axis where the profile comes closer to it. y is the length along the profile +/// from its middle, increasing toward the profile's lower end; on a closed contour that does not +/// touch the axis the middle is where its outer side crosses the middle height of its bounds. The +/// middle of the surface around the axis is the side that faces the viewer before the rotation +/// ([`surface_middle`]). Only the faces of the longest contour of a compound profile carry a cell. +#[derive(Clone, Debug, PartialEq)] +pub struct SurfaceCell { + /// The corners of the face on the page: its `points` before an edge along an end cap's ring + /// is curved. + pub page: Vec, + /// The surface point of each of `page`, in the same order. + pub uv: Vec, + /// The face shows the outside of the surface (the side away from the axis, or out of the + /// region a closed profile encloses) to the viewer. + pub outward: bool, +} + +/// The size of Revolve's side surface unrolled ([`SurfaceCell`]), which is the side of the +/// profile's longest contour: its width around the axis at that contour's largest distance from it +/// over the part of the sweep a viewer can see from the front ([`surface_span`]: half a turn at +/// most), and the contour's length. `None` when the profile has no length or lies on the axis. +pub fn revolve_surface_size(path: &PathData, bounds: Rect, options: Revolve) -> Option<(f64, f64)> { + let o = options.sanitized(); + let axis = revolve_projection(bounds, o).origin.x; + let sp = path.subpaths.get(longest_contour(path, axis)?)?; + let length = swept_length(sp, axis); + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + let radius = (0..segments).map(|i| sp.segment(i).bounding_box()).fold(0.0f64, |r, b| r.max((b.x0 - axis).abs()).max((b.x1 - axis).abs())); + let width = surface_span(o.angle.to_radians(), o.edge) * radius; + (width.is_finite() && length.is_finite() && width > 0.0 && length > 0.0).then_some((width, length)) +} + +/// The length of contour `sp` that sweeps a surface about `axis`: every segment but those that lie +/// wholly on the axis. +fn swept_length(sp: &vectorcraft_geom::SubPath, axis: f64) -> f64 { + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + (0..segments) + .map(|i| { + // A segment sweeps nothing only when the whole of it lies on the axis: its control + // points as well as its ends. + let c = sp.segment(i); + let on_axis = [c.p0, c.p1, c.p2, c.p3].iter().all(|p| (p.x - axis).abs() <= 1e-9); + if on_axis { 0.0 } else { ParamCurveArclen::arclen(&c, 1e-3) } + }) + .sum() +} + +/// The contour of `path` with the longest [`swept_length`] about `axis` (the first of equal ones), +/// which carries Revolve's graphics. `None` when no contour sweeps a surface. +fn longest_contour(path: &PathData, axis: f64) -> Option { + path.subpaths + .iter() + .map(|sp| swept_length(sp, axis)) + .enumerate() + .filter(|(_, l)| l.is_finite() && *l > 0.0) + .fold(None, |best: Option<(usize, f64)>, (k, l)| if best.is_some_and(|(_, top)| top >= l) { best } else { Some((k, l)) }) + .map(|(k, _)| k) +} + +/// The angle of a Revolve sweep of `sweep` radians, about an axis on the profile's `edge`, at the +/// middle of its side surface ([`SurfaceCell`]). Before the rotation the viewer looks straight at +/// the angle 90° with the axis on the left and 270° with it on the right, and sees the half turn +/// around it. The middle is the middle of the part of the sweep in that half turn, or half the +/// sweep when no part is in it. +fn surface_middle(sweep: f64, edge: Edge) -> f64 { + use std::f64::consts::{FRAC_PI_2, PI}; + match edge { + Edge::Left => FRAC_PI_2.min(sweep / 2.0), + Edge::Right if sweep > PI => (3.0 * FRAC_PI_2).min((PI + sweep) / 2.0), + Edge::Right => sweep / 2.0, + } +} + +/// The angle of a Revolve sweep of `sweep` radians about an axis on `edge` that a viewer sees from +/// the front before the rotation, about [`surface_middle`]: the part of the sweep in the half turn +/// facing the viewer, or the whole sweep when no part is in it. +fn surface_span(sweep: f64, edge: Edge) -> f64 { + use std::f64::consts::PI; + match edge { + Edge::Right if sweep > PI => (sweep - PI).min(PI), + _ => sweep.min(PI), + } +} + +/// The surface y ([`SurfaceCell`]) of each end of each piece of a contour's profile `profile` +/// about `axis`: the length along the profile from its middle, counted from just after the +/// pieces that lie on the axis (which sweep no surface and add no length) on a closed contour. +fn along_profile(profile: &[Point], axis: f64, closed: bool) -> Vec<[f64; 2]> { + let n = profile.len().saturating_sub(1); + if n == 0 { + return vec![]; + } + let on_axis = |k: usize| match (profile.get(k), profile.get(k + 1)) { + (Some(a), Some(b)) => (a.x - axis).abs() <= 1e-9 && (b.x - axis).abs() <= 1e-9, + _ => true, + }; + if closed + && !(0..n).any(&on_axis) + && let Some(out) = around_ring(profile, axis, n) + { + return out; + } + let start = if closed { (0..n).find(|&k| on_axis((k + n - 1) % n) && !on_axis(k)).unwrap_or(0) } else { 0 }; + let mut out = vec![[0.0; 2]; n]; + let mut length = 0.0; + for step in 0..n { + let k = (start + step) % n; + let piece = if on_axis(k) { 0.0 } else { profile.get(k).zip(profile.get(k + 1)).map_or(0.0, |(a, b)| a.distance(*b)) }; + if let Some(slot) = out.get_mut(k) { + *slot = [length, length + piece]; + } + length += piece; + } + // y increases toward the profile's lower end on the page: the end of an open contour, or the + // point half way along a closed one. + let middle = length / 2.0; + let first = profile.get(start).map_or(0.0, |p| p.y); + let end = if closed { + (0..n) + .map(|step| (start + step) % n) + .find_map(|k| { + let [a, b] = *out.get(k)?; + let (p, q) = (profile.get(k)?, profile.get(k + 1)?); + (b >= middle).then(|| p.y + (q.y - p.y) * if b > a { (middle - a) / (b - a) } else { 0.0 }) + }) + .unwrap_or(first) + } else { + profile.last().map_or(first, |p| p.y) + }; + let sign = if end >= first { 1.0 } else { -1.0 }; + out.into_iter().map(|[a, b]| [(a - middle) * sign, (b - middle) * sign]).collect() +} + +/// [`along_profile`] of a closed contour `profile` of `n` pieces with none on `axis`: y is 0 where +/// the contour's outer side (its crossing farthest from the axis) crosses the middle height of its +/// bounds and increases from there in the direction that runs down the page. The contour is cut at +/// its anchor nearest half way around from there, on its far side. `None` when the contour has no +/// height or no length. +fn around_ring(profile: &[Point], axis: f64, n: usize) -> Option> { + let RingFrame { ends, length, at, sign } = ring_frame(profile, axis, n)?; + // The cut is at the anchor nearest half way around from the crossing, so no piece runs across + // it: the pieces run on from there, once around. [`ring_cut`] puts an anchor there. + let opposite = at + length / 2.0; + let round = |d: f64| { + let d = d.rem_euclid(length); + d.min(length - d) + }; + let cut = ends + .iter() + .map(|[a, _]| *a) + .fold(None, |best: Option, a| if best.is_some_and(|b| round(b - opposite) <= round(a - opposite)) { best } else { Some(a) })?; + let first = (cut - at).rem_euclid(length) - length; + Some( + ends.into_iter() + .map(|[a, b]| { + let s = first + (a - cut).rem_euclid(length); + [s * sign, (s + (b - a)) * sign] + }) + .collect(), + ) +} + +/// Where a closed contour `profile` with no piece on `axis` is cut for [`around_ring`]: the piece +/// that the point half way around from its outer crossing lies inside, and how far along it, when +/// that point is not an anchor already. `None` otherwise, or for a contour that [`around_ring`] +/// does not measure. +fn ring_cut(profile: &[Point], axis: f64) -> Option<(usize, f64)> { + let n = profile.len().saturating_sub(1); + let on_axis = |k: usize| match (profile.get(k), profile.get(k + 1)) { + (Some(a), Some(b)) => (a.x - axis).abs() <= 1e-9 && (b.x - axis).abs() <= 1e-9, + _ => true, + }; + if n == 0 || (0..n).any(on_axis) { + return None; + } + let RingFrame { ends, length, at, .. } = ring_frame(profile, axis, n)?; + let opposite = (at + length / 2.0).rem_euclid(length); + ends.iter().enumerate().find_map(|(k, [a, b])| { + let t = if b > a { (opposite - a) / (b - a) } else { return None }; + (t > 1e-9 && t < 1.0 - 1e-9).then_some((k, t)) + }) +} + +/// The pieces of a closed contour, measured for [`around_ring`]. +struct RingFrame { + /// Each piece's length along the contour from its first anchor, at each end. + ends: Vec<[f64; 2]>, + /// The contour's length. + length: f64, + /// The length along the contour of its crossing farthest from the axis at the middle height + /// of its bounds. + at: f64, + /// 1 when the contour runs down the page there, -1 when it runs up. + sign: f64, +} + +/// [`RingFrame`] of closed contour `profile` of `n` pieces about `axis`. `None` when the contour +/// has no height or no length. +fn ring_frame(profile: &[Point], axis: f64, n: usize) -> Option { + let (top, bottom) = profile.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(t, b), p| (t.min(p.y), b.max(p.y))); + let mid = (top + bottom) / 2.0; + let mut ends = Vec::with_capacity(n); + let mut length = 0.0; + // The crossing farthest from the axis: its distance from it, its length along the contour + // and the direction down the page there. + let mut crossing: Option<(f64, f64, f64)> = None; + for k in 0..n { + let (p, q) = (profile.get(k)?, profile.get(k + 1)?); + let piece = p.distance(*q); + if (p.y - mid) * (q.y - mid) <= 0.0 && p.y != q.y { + let t = (mid - p.y) / (q.y - p.y); + let distance = (p.x + (q.x - p.x) * t - axis).abs(); + if crossing.is_none_or(|(far, ..)| distance > far) { + crossing = Some((distance, length + piece * t, if q.y > p.y { 1.0 } else { -1.0 })); + } + } + ends.push([length, length + piece]); + length += piece; + } + let (_, at, sign) = crossing?; + (length.is_finite() && length > 0.0).then_some(RingFrame { ends, length, at, sign }) } impl Face { @@ -471,6 +706,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi outline: None, cap: false, visibility_pieces: None, + surface: None, })) } @@ -703,6 +939,13 @@ fn revolve_projection(bounds: Rect, o: Revolve) -> shadow::Projection { shadow::Projection { view: o, origin: Point::new(axis, center_y), eye } } +/// The most faces one Revolve surface with sanitized options `o` holds. With graphics mapped, a +/// closed contour may take one more edge where its side is cut ([`ring_cut`]): the surface may +/// hold that edge's faces past [`MAX_REVOLVE_FACES`], which the profile was planned within. +fn revolve_face_limit(o: Revolve) -> usize { + MAX_REVOLVE_FACES + if o.map_surface { o.steps().saturating_mul(2) } else { 0 } +} + /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, /// each turned out of the region `rule` fills. fn revolve_faces( @@ -720,10 +963,11 @@ fn revolve_faces( // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so // each part's normal turns toward a hole only where it walls one. let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; + let face_limit = revolve_face_limit(o); // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { mesh.push((vec![face], normal)); - if mesh.len() > MAX_REVOLVE_FACES { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } + if mesh.len() > face_limit { Err(TOO_COMPLEX_SURFACE.to_string()) } else { Ok(()) } }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); @@ -734,6 +978,27 @@ fn revolve_faces( let smooth = o.shade && o.smooth_shading; let [lx, ly, lz] = o.light_direction(); let light = V3(lx, ly, lz); + // The side surface's angle at each end of step `j` ([`SurfaceCell`]): from the middle of the + // surface, increasing to the viewer's right. The middle is the middle of the part of the sweep + // that faces the viewer before the rotation, and at most the angle that faces the viewer + // straight on: 90° with the axis on the profile's left, 270° with it on the right. A sweep + // with no part facing the viewer has its middle at half the sweep. A full turn is cut at the + // back, at the edge between two steps nearest half a turn from the middle: each step is placed + // by its own middle, within half a turn of the surface's middle, so the steps meet edge to + // edge on either side of the cut. + let middle = surface_middle(sweep, o.edge); + let full = o.angle >= 360.0; + let surface_angle = |j: usize| { + let (from, to) = (sweep * j as f64 / steps as f64, sweep * (j + 1) as f64 / steps as f64); + let half = (to - from) / 2.0; + let mut start = middle - from; + if full { + start = (start - half + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI + half; + } + [start, start - (to - from)] + }; + // Only the longest contour of a compound profile carries the graphics. + let surface_contour = o.map_surface.then(|| longest_contour(path, axis)).flatten(); let mut faces = Vec::new(); let vertex = |p: Point, step: usize| { let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); @@ -748,7 +1013,7 @@ fn revolve_faces( if let Some(face) = face { faces.push(face); mesh_cost += cost; - if mesh_cost > MAX_REVOLVE_FACES { + if mesh_cost > face_limit { return Err(TOO_COMPLEX_SURFACE.into()); } } @@ -833,9 +1098,26 @@ fn revolve_faces( let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); if area < 0.0 { -1.0 } else { 1.0 } }; + // A closed contour that does not touch the axis has its side cut opposite the middle of its + // outer side; an anchor goes there, so no face runs across the cut. That one anchor is not + // counted against the limit on the profile's points. + if sp.closed + && surface_contour == Some(contour) + && let Some((k, t)) = ring_cut(&profile, axis) + && let (Some(a), Some(b), Some([ta, tb]), Some(hole)) = + (profile.get(k), profile.get(k + 1), tangents.get(k).copied(), hole_edges.get(k).copied()) + { + let point = *a + (*b - *a) * t; + let middle = ta + (tb - ta) * t; + profile.insert(k + 1, point); + tangents.splice(k..=k, [[ta, middle], [middle, tb]]); + hole_edges.insert(k + 1, hole); + } + let along = along_profile(&profile, axis, sp.closed); for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { let [a, b] = pair else { continue }; let turn = if hole { -turn } else { turn }; + let [va, vb] = along.get(i).copied().unwrap_or_default(); if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve profile contains invalid coordinates".into()); } @@ -851,20 +1133,42 @@ fn revolve_faces( } let first = (i == 0).then(|| rings.first().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); let last = (i + 2 == profile.len()).then(|| rings.last().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); - let projected = |vertices: &[V3], normals: Option<&[V3]>, edges: &[(usize, &caps::RingArc, bool)]| { + // Where the corners lie on the unrolled side surface, and whether the face shows + // its outside: the edge's outward normal swept to the middle of the step. + // x is the angle times the corner's own distance from the axis. + let [t0, t1] = surface_angle(j); + let (ra, rb) = ((a.x - axis).abs(), (b.x - axis).abs()); + let corners = [Point::new(t0 * ra, va), Point::new(t0 * rb, vb), Point::new(t1 * rb, vb), Point::new(t1 * ra, va)]; + let on_surface = surface_contour == Some(contour); + let outward = { + let (dr, dy) = ((b.x - a.x) * turn, (b.y - a.y) * turn); + let (s, c) = (sweep * (j as f64 + 0.5) / steps as f64).sin_cos(); + let n = o.rotate(V3(dy * c, -dr, dy * s)); + let middle = [v0, v1, v2, v3].iter().fold(V3::default(), |m, v| V3(m.0 + v.0 / 4.0, m.1 + v.1 / 4.0, m.2 + v.2 / 4.0)); + let toward = eye.map_or(V3(0.0, 0.0, 1.0), |e| V3(-middle.0, -middle.1, e - middle.2)); + n.dot(toward) > 0.0 + }; + let projected = |vertices: &[V3], normals: Option<&[V3]>, edges: &[(usize, &caps::RingArc, bool)], uv: &[usize]| { let mut face = project_face(vertices, normals, o, Point::new(axis, center_y), eye, light)?; if let Some(face) = &mut face { + let page = face.points.clone(); caps::curve_edges(face, edges); + if on_surface { + let uv = uv.iter().filter_map(|k| corners.get(*k).copied()).collect(); + face.surface = Some(SurfaceCell { page, uv, outward }); + } } Ok::<_, String>(face) }; if smooth { let [n0, n1, n2, n3] = [normal(ta, j), normal(tb, j), normal(tb, j + 1), normal(ta, j + 1)]; - emit(projected(&[v0, v1, v2], Some(&[n0, n1, n2]), &last.map(|arc| (1, arc, false)).into_iter().collect::>())?, 1)?; - emit(projected(&[v0, v2, v3], Some(&[n0, n2, n3]), &first.map(|arc| (2, arc, true)).into_iter().collect::>())?, 1)?; + let last: Vec<_> = last.map(|arc| (1, arc, false)).into_iter().collect(); + let first: Vec<_> = first.map(|arc| (2, arc, true)).into_iter().collect(); + emit(projected(&[v0, v1, v2], Some(&[n0, n1, n2]), &last, &[0, 1, 2])?, 1)?; + emit(projected(&[v0, v2, v3], Some(&[n0, n2, n3]), &first, &[0, 2, 3])?, 1)?; } else { let edges: Vec<_> = last.map(|arc| (1, arc, false)).into_iter().chain(first.map(|arc| (3, arc, true))).collect(); - emit(projected(&[v0, v1, v2, v3], None, &edges)?, 1)?; + emit(projected(&[v0, v1, v2, v3], None, &edges, &[0, 1, 2, 3])?, 1)?; } } } diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index 1b0ffa3ffc..fe42769ebb 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -184,6 +184,190 @@ fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() { assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap()); } #[test] +fn side_faces_lie_on_the_unrolled_surface_and_caps_do_not() { + let (p, b) = cylinder(); + // Without graphics no face carries a place on the surface. + let plain = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + assert!(plain.iter().all(|f| f.surface.is_none())); + let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + assert_eq!(faces.len(), plain.len()); + assert!(faces.iter().filter(|f| f.cap).all(|f| f.surface.is_none())); + let side: Vec<_> = faces.iter().filter(|f| !f.cap).collect(); + assert!(!side.is_empty() && side.iter().all(|f| f.surface.as_ref().is_some_and(|c| c.uv.len() == c.page.len() && c.uv.len() == 3))); + for f in &side { + let cell = f.surface.as_ref().unwrap(); + // Straight on, x around the axis is the angle from the front times the radius 40: a corner + // at page x lies at 40 asin(x / 40) on the front half; y runs 40 each way from the middle, + // downward. + for (q, uv) in cell.page.iter().zip(&cell.uv) { + assert!((uv.y - (q.y - 50.0)).abs() < 1e-9, "{q:?} {uv:?}"); + if cell.outward { + assert!((uv.x - 40.0 * (q.x / 40.0).clamp(-1.0, 1.0).asin()).abs() < 1e-6, "{q:?} {uv:?}"); + } + } + // The outside faces the viewer on the front half only. + let mean = cell.uv.iter().map(|p| p.x).sum::() / cell.uv.len() as f64; + assert_eq!(cell.outward, mean.abs() < 40.0 * std::f64::consts::FRAC_PI_2, "{mean}"); + } + // A quarter sweep's middle is the middle of the sweep, 45° around the axis. + let quarter = revolve(&p, b, Revolve { map_surface: true, angle: 90.0, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let xs: Vec = quarter.iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.x)).collect(); + let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + let reach = 40.0 * std::f64::consts::FRAC_PI_4; + assert!((lo + reach).abs() < 1e-9 && (hi - reach).abs() < 1e-9, "{lo} {hi}"); + let (w, h) = revolve_surface_size(&p, b, Revolve::default()).unwrap(); + assert!((w - 40.0 * std::f64::consts::PI).abs() < 1e-9 && (h - 80.0).abs() < 1e-3, "{w} {h}"); + assert_eq!(revolve_surface_size(&p, Rect::new(40.0, 10.0, 40.0, 90.0), Revolve::default()), None); + // A curve that starts and ends on the axis but bows away from it sweeps a side with a length. + let mut bow = BezPath::new(); + bow.move_to((0.0, 0.0)); + bow.curve_to((40.0, 0.0), (40.0, 80.0), (0.0, 80.0)); + let (w, h) = revolve_surface_size(&PathData::from_bezpath(&bow), Rect::new(0.0, 0.0, 30.0, 80.0), Revolve::default()).unwrap(); + assert!((w - 30.0 * std::f64::consts::PI).abs() < 1e-9 && h > 80.0, "{w} {h}"); +} +#[test] +fn the_side_keeps_its_length_around_the_axis_at_every_distance_from_it() { + // A step: 40 from the axis, 20 between y 40 and 60, then 40 again. + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + for p in [(40.0, 40.0), (20.0, 40.0), (20.0, 60.0), (40.0, 60.0), (40.0, 90.0)] { + bp.line_to(p); + } + let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)); + let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let (mut narrow, mut wide) = (0.0f64, 0.0f64); + for cell in faces.iter().filter_map(|f| f.surface.as_ref()).filter(|c| c.outward) { + // The faces of the narrow part run from y 40 to 60; those of the wide parts reach past + // them; the steps between lie at y 40 or 60 only. + let at = |y: f64| cell.page.iter().any(|q| (q.y - y).abs() < 1e-9); + let radius = if at(40.0) && at(60.0) { + Some(20.0) + } else if cell.page.iter().any(|q| q.y < 40.0 - 1e-9 || q.y > 60.0 + 1e-9) { + Some(40.0) + } else { + None + }; + for (q, uv) in cell.page.iter().zip(&cell.uv) { + // Straight on, a corner at page x that is r from the axis lies r asin(x / r) around it. + let fits = |r: f64| (uv.x - r * (q.x / r).clamp(-1.0, 1.0).asin()).abs() < 1e-6; + match radius { + Some(r) => { + assert!(fits(r), "{r}: {q:?} {uv:?}"); + let reach = if r < 30.0 { &mut narrow } else { &mut wide }; + *reach = reach.max(uv.x.abs()); + } + None => assert!(fits(20.0) || fits(40.0), "{q:?} {uv:?}"), + } + } + } + // The half seen from the front reaches a quarter turn each way: 10π at 20 from the axis, 20π + // at 40. + let quarter = std::f64::consts::FRAC_PI_2; + assert!((narrow - 20.0 * quarter).abs() < 1.0 && (wide - 40.0 * quarter).abs() < 2.0, "{narrow} {wide}"); +} +#[test] +fn with_the_axis_on_the_right_the_middle_of_the_side_faces_the_viewer() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 10.0)); + bp.line_to((0.0, 90.0)); + let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)); + let q = 40.0 * std::f64::consts::FRAC_PI_4; + // The side's reach around the axis either way of its middle: for 270° the middle is 225°, in + // the middle of the part from 180° to 270° that faces the viewer; a quarter turn faces away, + // and its middle is half the sweep. + for (angle, lo_expected, hi_expected) in [(270.0, -q, 5.0 * q), (90.0, -q, q)] { + let o = Revolve { map_surface: true, edge: Edge::Right, angle, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }; + let xs: Vec = revolve(&p, b, o).unwrap().iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.x)).collect(); + let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + assert!((lo - lo_expected).abs() < 1e-6 && (hi - hi_expected).abs() < 1e-6, "{angle}: {lo} {hi}"); + } + // A new graphic fits the part of the sweep that faces the viewer: 90° of a 270° sweep, 20° of + // a 200° one, all of a quarter turn that faces away. + for (angle, seen) in [(270.0f64, 90.0f64), (200.0, 20.0), (90.0, 90.0), (360.0, 180.0)] { + let (w, _) = revolve_surface_size(&p, b, Revolve { edge: Edge::Right, angle, ..Default::default() }).unwrap(); + assert!((w - 40.0 * seen.to_radians()).abs() < 1e-9, "{angle}: {w}"); + } + // A full turn: straight on, the axis is at x 40, a corner at page x lies 40 asin((x - 40) / 40) + // around it, and the front half shows its outside. + let faces = revolve(&p, b, Revolve { map_surface: true, edge: Edge::Right, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let cells: Vec<_> = faces.iter().filter_map(|f| f.surface.as_ref()).collect(); + assert!(cells.iter().any(|c| c.outward)); + for cell in &cells { + let mean = cell.uv.iter().map(|p| p.x).sum::() / cell.uv.len() as f64; + assert_eq!(cell.outward, mean.abs() < 40.0 * std::f64::consts::FRAC_PI_2, "{mean}"); + if cell.outward { + for (q, uv) in cell.page.iter().zip(&cell.uv) { + assert!((uv.x - 40.0 * ((q.x - 40.0) / 40.0).clamp(-1.0, 1.0).asin()).abs() < 1e-6, "{q:?} {uv:?}"); + } + } + } +} +#[test] +fn only_the_longest_contour_of_a_compound_profile_carries_the_side_surface() { + let mut bp = BezPath::new(); + bp.move_to((40.0, 10.0)); + bp.line_to((40.0, 90.0)); + bp.move_to((20.0, 30.0)); + bp.line_to((20.0, 50.0)); + let (p, b) = (PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0)); + let faces = revolve(&p, b, Revolve { map_surface: true, rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap(); + let short = |f: &Face| f.points.iter().all(|q| q.y > 29.0 && q.y < 51.0); + assert!(faces.iter().any(|f| !f.cap && short(f)) && faces.iter().any(|f| !f.cap && !short(f))); + for f in faces.iter().filter(|f| !f.cap) { + assert_eq!(f.surface.is_some(), !short(f), "{:?}", f.points); + } + // The side's size is that contour's: with the longest contour 10 from the axis and a shorter + // one 40 from it, the width is half a turn at 10 and the length the longest contour's 80. + let mut bp = BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.line_to((10.0, 90.0)); + bp.move_to((40.0, 30.0)); + bp.line_to((40.0, 50.0)); + let (w, h) = revolve_surface_size(&PathData::from_bezpath(&bp), b, Revolve::default()).unwrap(); + assert!((w - 10.0 * std::f64::consts::PI).abs() < 1e-9 && (h - 80.0).abs() < 1e-3, "{w} {h}"); +} +/// The square ring of [`a_ring_profile_is_measured_from_its_outer_side_at_mid_height`] with an +/// anchor in the middle of its inner side. +fn split_square() -> [Point; 6] { + [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0), (20.0, 40.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y)) +} +#[test] +fn a_ring_profile_is_measured_from_its_outer_side_at_mid_height() { + // A square from 20 to 40 from the axis, starting at its top inner corner and running to the + // right first (down the outer side). + let ring = [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(along_profile(&ring, 0.0, true), vec![[-30.0, -10.0], [-10.0, 10.0], [10.0, 30.0], [30.0, 50.0]]); + // Wound the other way the outer side still runs down the page as y increases. The cut is at + // an inner corner, so each piece's y runs one way across it. + let back = [(20.0, 30.0), (20.0, 50.0), (40.0, 50.0), (40.0, 30.0), (20.0, 30.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(along_profile(&back, 0.0, true), vec![[50.0, 30.0], [30.0, 10.0], [10.0, -10.0], [-10.0, -30.0]]); + // The cut falls half way along a long inner side: Revolve puts an anchor there first, and y + // then runs half the contour's length each way. + let tall = [(20.0, 0.0), (40.0, 0.0), (40.0, 100.0), (20.0, 100.0), (20.0, 0.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(ring_cut(&tall, 0.0), Some((3, 0.5))); + assert_eq!(ring_cut(&ring, 0.0), Some((3, 0.5))); + assert_eq!(ring_cut(&split_square(), 0.0), None); + assert_eq!(along_profile(&split_square(), 0.0, true), vec![[-30.0, -10.0], [-10.0, 10.0], [10.0, 30.0], [30.0, 40.0], [-40.0, -30.0]]); + let split = [(20.0, 0.0), (40.0, 0.0), (40.0, 100.0), (20.0, 100.0), (20.0, 50.0), (20.0, 0.0)].map(|(x, y)| Point::new(x, y)); + assert_eq!(along_profile(&split, 0.0, true), vec![[-70.0, -50.0], [-50.0, 50.0], [50.0, 70.0], [70.0, 120.0], [-120.0, -70.0]]); + let mut bp = BezPath::new(); + bp.move_to((20.0, 0.0)); + for p in [(40.0, 0.0), (40.0, 100.0), (20.0, 100.0)] { + bp.line_to(p); + } + bp.close_path(); + let (path, bounds, o) = + (PathData::from_bezpath(&bp), Rect::new(20.0, 0.0, 40.0, 100.0), Revolve { map_surface: true, offset: 20.0, ..Default::default() }); + let faces = revolve(&path, bounds, o).unwrap(); + // Without graphics the profile takes no anchor at the cut: its inner side stays one edge. + let plain = revolve(&path, bounds, Revolve { map_surface: false, ..o }).unwrap(); + assert!(plain.len() < faces.len() && plain.iter().all(|f| f.surface.is_none()), "{} {}", plain.len(), faces.len()); + let ys: Vec = faces.iter().filter_map(|f| f.surface.as_ref()).flat_map(|c| c.uv.iter().map(|p| p.y)).collect(); + assert!(!ys.is_empty() && ys.iter().all(|y| y.abs() <= 120.0 + 1e-9), "{ys:?}"); + let (w, h) = revolve_surface_size(&path, bounds, o).unwrap(); + assert!((h - 240.0).abs() < 1e-6 && (w - 40.0 * std::f64::consts::PI).abs() < 1e-9, "{w} {h}"); +} +#[test] fn malformed_and_degenerate_inputs_are_bounded() { let (p, b) = cylinder(); let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap(); diff --git a/crates/three-d/src/tests/profile_limits.rs b/crates/three-d/src/tests/profile_limits.rs index 148cf4fccc..e785c959d8 100644 --- a/crates/three-d/src/tests/profile_limits.rs +++ b/crates/three-d/src/tests/profile_limits.rs @@ -103,6 +103,35 @@ fn the_profiles_of_one_object_share_one_budget() { assert_eq!(revolve_curve_samples([&both], o), Err(refused)); } +#[test] +fn with_graphics_a_ring_at_the_face_limit_still_takes_the_edge_its_cut_adds() { + // A closed many-sided ring, 50 clear of the axis, with the middle of an edge at the middle of + // its outer side and, with an even number of edges, at the middle of its inner side. + let ring = |edges: usize| { + let anchors = (0..edges) + .map(|i| { + let (s, c) = ((i as f64 + 0.5) * std::f64::consts::TAU / edges as f64).sin_cos(); + Anchor::corner(Point::new(300.0 + 100.0 * c, 100.0 * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) + }; + // Tilted, so the flat edges at the top and bottom draw faces too. + let plain = Revolve { offset: 50.0, rotation_x: -20.0, ..Revolve::default() }; + let mapped = Revolve { map_surface: true, ..plain }; + // 1,280 edges of 128 faces each are the most that fit, with graphics or without. + for o in [plain, mapped] { + assert_eq!(revolve_curve_samples([&ring(1280)], o), Ok(CURVE_SAMPLES)); + assert!(revolve_curve_samples([&ring(1281)], o).unwrap_err().contains("reduce Segments")); + } + let faces = revolved(&ring(1280), plain).unwrap(); + assert_eq!(faces.len(), MAX_REVOLVE_FACES); + assert!(faces.iter().all(|f| f.surface.is_none())); + // With graphics the side is cut in the middle of the inner edge, which makes one edge more: + // its faces are taken past the limit. + assert_eq!(revolved(&ring(1280), mapped).unwrap().len(), MAX_REVOLVE_FACES + 128); +} + #[test] fn deeply_nested_contours_stop_the_search_for_holes() { // Concentric triangles: each tests every contour outside it. diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index db8f5b0df7..424242828b 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -302,6 +302,7 @@ mod tests { outline: None, cap: false, visibility_pieces: None, + surface: None, } } diff --git a/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs index af1194d78b..e09df663bb 100644 --- a/crates/tools/src/blendwidget.rs +++ b/crates/tools/src/blendwidget.rs @@ -5,8 +5,11 @@ //! the canvas draws from the halfway point to the widget grows with it. The upper one sets the //! blend's color shift (`colorShift`, -100 to 100) and the lower one its easing strength (`ramp`, //! 0 to 100). The lower one shows only when the steps follow a spine (a replaced or edited spine, -//! or Align to Path), where easing moves them; a blend made without a spine shows neither. A drag along the spine's direction at the press -//! changes the value by one per screen pixel, starting from the value at the press, and the arrow +//! or Align to Path), where easing moves them. A blend made without a spine shows the upper one +//! alone, placed in the same way against the straight lines between its key centers that its steps +//! are laid out along ([`blend_layout_line`]); the canvas does not draw those lines. A drag along +//! the spine's direction at the press changes the value by one per screen pixel, starting from the +//! value at the press, and the arrow //! keys during the drag change it by one (Shift: by ten); Escape or a switch to another tool //! cancels the drag and puts the value back. Each drag previews //! `object.blend.setEasing` and is one undo step, or none when the value ends where it started; a @@ -14,7 +17,7 @@ //! from the same geometry. use serde_json::json; -use vectorcraft_doc::live::{Spine, blend_spine}; +use vectorcraft_doc::live::{Spine, blend_layout_line}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Point, Rect, Vec2}; @@ -69,7 +72,8 @@ impl BlendControl { pub struct BlendWidget { pub id: NodeId, pub control: BlendControl, - /// The spine's halfway point, in the document. + /// The spine's halfway point, in the document (for a blend without a spine, the halfway point of + /// the line its steps are laid out along). pub on: Point, /// The widget's center in the document: [`OFFSET_PX`] from `on`, or further out where other /// artwork crowds that spot. @@ -84,9 +88,10 @@ impl BlendWidget { /// The widgets of the selection at `zoom` (screen px per document point), with `up` the /// document direction of the screen's up: the Appearance widget, then the Easing widget. Empty /// unless the selection is one whole blend, visible and unlocked, with two or more keys and a - /// spine of some length, and no anchors are selected. Each widget sits at the first spot out - /// from the spine, in [`OFFSET_PX`] steps, that the boxes of fewer than three other visible - /// objects hold, at most nine steps out; where every spot is crowded it stays at the first. + /// spine (or, without one, a step layout line) of some length, and no anchors are selected. + /// Each widget sits at the first spot out from the spine, in [`OFFSET_PX`] steps, that the + /// boxes of fewer than three other visible objects hold, at most nine steps out; where every + /// spot is crowded it stays at the first. pub fn of(doc: &Document, selection: &Selection, zoom: f64, up: Vec2) -> Vec { let up_len = up.hypot(); let usable = |v: f64| v.is_finite() && v > 0.0; @@ -98,7 +103,7 @@ impl BlendWidget { if children.len() < 2 || !doc.is_editable(*id) { return vec![]; } - let Some(spine) = blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)) else { return vec![] }; + let Some(spine) = blend_layout_line(children, spec).and_then(|path| Spine::new(&path)) else { return vec![] }; if !usable(spine.length()) { return vec![]; } @@ -430,9 +435,60 @@ mod tests { // One key. let (d, _, s) = blend_doc(&[(100.0, 200.0)], eased()); assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "one key"); - // A blend made without a spine (Create Spine › Never), Align to Path included. - let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); - assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine"); + // Without a spine: locked or hidden, or keys on one spot. + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + for hide in [false, true] { + let mut d = d.clone(); + let n = d.node_mut(id).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine, hidden: {hide}"); + } + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine, no length"); + } + + /// A blend made without a spine (Create Spine › Never), Align to Path included, shows the + /// Appearance widget alone, at the halfway point by length of the straight lines between its + /// key centers. + #[test] + fn a_blend_without_a_spine_shows_the_appearance_widget_alone() { + let no_spine = || BlendSpec { no_spine: true, ..eased() }; + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert_eq!(w.iter().map(|w| w.control).collect::>(), [BlendControl::Appearance], "{w:?}"); + assert!(w[0].id == id && near(w[0].on, Point::new(200.0, 200.0)) && near(w[0].at, Point::new(200.0, 184.0)), "{w:?}"); + assert_eq!(w[0].value, -20.0); + // Halfway along by length, not the middle key: (0, 0), (100, 0), (100, 300) is 400 long. + let (d, _, s) = blend_doc(&[(0.0, 0.0), (100.0, 0.0), (100.0, 300.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(w.len() == 1 && near(w[0].on, Point::new(100.0, 100.0)), "{w:?}"); + // There the line runs down, so a drag down adds. + assert!(near(Point::new(w[0].along.x, w[0].along.y), Point::new(0.0, 1.0)), "{w:?}"); + // Two keys on one spot and a third away: the line still has length. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0), (300.0, 200.0)], no_spine()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(w.len() == 1 && near(w[0].on, Point::new(200.0, 200.0)), "{w:?}"); + } + + /// With the Selection or the Blend tool, a drag of a spineless blend's Appearance widget + /// previews the color shift alone, in one undo step. + #[test] + fn dragging_the_appearance_widget_of_a_blend_without_a_spine() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 184.0); + assert!(over_widget(&c, at) && !over_widget(&c, Point::new(200.0, 216.0)), "no Easing widget below"); + let tools: [Box; 2] = [Box::new(SelectionTool::default()), Box::new(crate::meshblend::BlendTool::default())]; + for mut t in tools { + assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(30.0, 0.0))); + assert_eq!( + a, + vec![Action::Begin(LABEL.into()), Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": 10.0}))] + ); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at + Vec2::new(30.0, 0.0))), vec![Action::Commit]); + } } /// A drag of the Easing widget along the spine changes the ramp by one per pixel from its diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 2b6cef4532..e2d6c9f77e 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -480,6 +480,8 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); /// Select Strokes off, Alt-click selects the face under the pointer as a click does and a marquee /// takes no edges; with Select Fills off, a click selects the edge nearest the pointer as Alt-click /// does and a marquee takes no faces; with both off, a click or a marquee selects no face or edge. +/// The pointer shows what a click at that point selects ([`LivePaintSelectionTool::pick_at`]): a +/// face, an edge, or nothing. From the press to the release it keeps the look it had at the press. pub struct LivePaintSelectionTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -502,6 +504,8 @@ struct LpDrag { shift: bool, /// The face or edge under the press, which a click selects (or with Shift toggles). hit: Option, + /// The cursor at the press, which the drag keeps. + cursor: Cursor, } impl LpDrag { @@ -538,6 +542,22 @@ impl LivePaintSelectionTool { (false, false) => None, } } + + /// The face or edge a click at `p` selects, Alt held or not, with its cursor: the visible part of + /// the kind [`LivePaintSelectionTool::picks_edge`] asks for, of a Live Paint group the tool takes + /// ([`lp_part_at`], [`lp_selectable`]), and [`Cursor::LivePaintSelectFace`] or + /// [`Cursor::LivePaintSelectEdge`]; None and [`Cursor::LivePaintSelectOutside`] where a click + /// selects no face or edge. + fn pick_at(&self, cx: &ToolContext, p: Point, alt: bool) -> (Option, Cursor) { + let Some(edge) = self.picks_edge(alt) else { return (None, Cursor::LivePaintSelectOutside) }; + let hit = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)).and_then(|(_, part)| part).map(|n| n.id); + let cursor = match (hit, edge) { + (None, _) => Cursor::LivePaintSelectOutside, + (Some(_), true) => Cursor::LivePaintSelectEdge, + (Some(_), false) => Cursor::LivePaintSelectFace, + }; + (hit, cursor) + } } /// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and @@ -592,9 +612,8 @@ impl Tool for LivePaintSelectionTool { vec![] } PointerKind::Down => { - let part = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e, &|id| lp_selectable(cx, id))); - let hit = part.and_then(|(_, part)| part).map(|n| n.id); - self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit }); + let (hit, cursor) = self.pick_at(cx, p, ev.mods.alt); + self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit, cursor }); vec![] } PointerKind::Drag => { @@ -652,8 +671,11 @@ impl Tool for LivePaintSelectionTool { lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, self.width as f32) } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + match &self.drag { + Some(d) => d.cursor, + None => self.pick_at(cx, p, m.alt).1, + } } fn options(&self) -> Value { diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 89ce04eb3d..298e6a1907 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1636,6 +1636,84 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); } +/// The Live Paint Selection pointer shows what a click at the point selects, from the point and +/// the modifier keys alone (no move first): a face, an edge, or nothing. Groups the tool does not +/// take (locked, hidden, outside isolation) show nothing to select. A press keeps its look until +/// the release. +#[test] +fn live_paint_selection_cursor_shows_what_a_click_selects() { + let mut d = Document::new(500.0, 500.0); + let (g, _) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (other, _) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let plain = Mods::default(); + let alt = Mods { alt: true, ..Default::default() }; + let (face, edge, out) = (Cursor::LivePaintSelectFace, Cursor::LivePaintSelectEdge, Cursor::LivePaintSelectOutside); + let at = |t: &LivePaintSelectionTool, cx: &ToolContext, x: f64, m: Mods| t.cursor(cx, Point::new(x, 50.0), m); + let mut t = LivePaintSelectionTool::default(); + { + let cx = cx(&d, &s, &p); + // Both options on: a face inside the square; with Alt the side within reach, inside or + // outside the face; nothing away from the groups or from every side. + assert_eq!( + [at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, plain), at(&t, &cx, 102.0, plain), at(&t, &cx, 150.0, plain)], + [face, face, out, out] + ); + assert_eq!([at(&t, &cx, 98.5, alt), at(&t, &cx, 102.0, alt), at(&t, &cx, 50.0, alt), at(&t, &cx, 150.0, alt)], [edge, edge, out, out]); + // The cursor agrees with what a click there selects. + let face_id = faces(d.node(g).unwrap())[0].id; + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 50.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [face_id.0] }))]); + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 50.0, 50.0).with_mods(alt)), vec![Action::Exec("select.none".into(), json!({}))]); + // Select Fills off: a side without Alt too, never a face. + t.set_option("selectFills", &json!(false)); + assert_eq!([at(&t, &cx, 98.5, plain), at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt)], [edge, out, edge]); + // Select Strokes off: the face with Alt too, never a side. + t.set_option("selectFills", &json!(true)); + t.set_option("selectStrokes", &json!(false)); + assert_eq!([at(&t, &cx, 98.5, alt), at(&t, &cx, 102.0, alt)], [face, out]); + // Both off: nothing. + t.set_option("selectFills", &json!(false)); + assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt)], [out, out]); + } + let mut t = LivePaintSelectionTool::default(); + // A locked group shows nothing to select; the other group still does. + d.node_mut(g).unwrap().locked = true; + { + let cx = cx(&d, &s, &p); + assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt), at(&t, &cx, 250.0, plain)], [out, out, face]); + } + d.node_mut(g).unwrap().locked = false; + // In isolation mode, a group outside the isolated art shows nothing to select. + { + let cx = ToolContext { isolation: Some(other), ..cx(&d, &s, &p) }; + assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 250.0, plain)], [out, face]); + } + // A hidden group shows nothing to select. + d.node_mut(g).unwrap().visible = false; + { + let cx = cx(&d, &s, &p); + assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt)], [out, out]); + } + d.node_mut(g).unwrap().visible = true; + // A press keeps its look while the drag goes elsewhere, and the release lets it follow again. + let cx = cx(&d, &s, &p); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 50.0)); + assert_eq!(at(&t, &cx, 150.0, plain), face); + t.pointer(&cx, &ev(PointerKind::Up, 150.0, 50.0)); + assert_eq!(at(&t, &cx, 150.0, plain), out); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); + assert_eq!(at(&t, &cx, 50.0, plain), out); + t.key(&cx, ToolKey::Escape, plain); + assert_eq!(at(&t, &cx, 50.0, plain), face, "Escape ends the press"); + for c in [face, edge, out] { + assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); + assert_eq!(c.stand_in(), None, "{c:?} is drawn"); + } +} + /// Presses at `from` with `m`, drags to `to` and releases there; returns what the release does /// (the press and the drag do nothing). fn lp_drag(t: &mut LivePaintSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), m: Mods) -> Vec { diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 741e962290..e6db6069c7 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -117,6 +117,53 @@ impl GlyphSnap { } } +/// The Pixel Snapping Options (`view.snapToPixel.options`): which edits View › Snap to Pixel lands +/// on whole pixels. All on by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PixelSnap { + /// While Drawing: points placed by the drawing tools, ruler origins and measurements. + pub drawing: bool, + /// While Moving: the selection, its anchors and segments, artboards and ruler guides moved. + pub moving: bool, + /// While Scaling: the edges a bounding-box, Free Transform, Scale tool, artboard or slice + /// handle drags. + pub scaling: bool, +} + +impl Default for PixelSnap { + fn default() -> Self { + Self { drawing: true, moving: true, scaling: true } + } +} + +impl PixelSnap { + /// These options with the ones `p` names (booleans), or why `p` can't set them. + pub fn merged(self, p: &serde_json::Value) -> Result { + let mut o = serde_json::to_value(self).map_err(|e| e.to_string())?; + match (p, o.as_object_mut()) { + (serde_json::Value::Null, _) => {} + (serde_json::Value::Object(given), Some(cur)) => { + for (k, v) in given { + let slot = cur.get_mut(k).ok_or_else(|| format!("unknown Pixel Snapping option `{k}`"))?; + *slot = serde_json::Value::Bool(v.as_bool().ok_or_else(|| format!("`{k}` must be true or false"))?); + } + } + _ => return Err("the Pixel Snapping options must be an object".into()), + } + serde_json::from_value(o).map_err(|e| e.to_string()) + } +} + +/// The kind of edit a snap serves, which picks the Pixel Snapping option that applies to it +/// ([`ToolContext::snaps_to_pixel`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PixelOp { + Drawing, + Moving, + Scaling, +} + /// Read-only context a tool sees. pub struct ToolContext<'a> { pub doc: &'a Document, @@ -141,8 +188,11 @@ pub struct ToolContext<'a> { pub snap_to_grid: bool, /// Bounding box shown (View → Show/Hide Bounding Box). pub show_bbox: bool, - /// View → Snap to Pixel. + /// View → Snap to Pixel. Which edits it snaps is [`Self::pixel_snap`]'s choice + /// ([`Self::snaps_to_pixel`]). pub snap_to_pixel: bool, + /// The Pixel Snapping Options: whether Snap to Pixel snaps drawing, moving and scaling. + pub pixel_snap: PixelSnap, /// View → Snap to Point: with Smart Guides off, dragged selections, drawn points and picked /// points (a transform's reference point) land on anchors and ruler guides within /// [`Self::snap_tolerance`]. @@ -342,6 +392,16 @@ impl ToolContext<'_> { let (dist, ang, dx, dy) = xform::measure(a, b); format!("D: {}\nAngle: {ang:.1}°\n{}", self.len(dist), self.offset_label(dx, dy)) } + /// Does View › Snap to Pixel land an edit of kind `op` on whole pixels: is it on, with the + /// Pixel Snapping option for `op` on too? + pub fn snaps_to_pixel(&self, op: PixelOp) -> bool { + self.snap_to_pixel + && match op { + PixelOp::Drawing => self.pixel_snap.drawing, + PixelOp::Moving => self.pixel_snap.moving, + PixelOp::Scaling => self.pixel_snap.scaling, + } + } /// What Snap to Grid snaps to: the grid's subdivisions (or its lines without any). pub fn grid_step(&self) -> f64 { self.doc.grid.spacing / self.doc.grid.subdivisions.max(1) as f64 diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index d5cdd05bc5..584149da8f 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -148,6 +148,14 @@ pub enum Cursor { LivePaintBucketFace, /// The Live Paint Bucket where a click paints an edge of a Live Paint group: a brush. LivePaintBucketEdge, + /// Live Paint Selection over a face of a Live Paint group that a click selects: the arrow with a + /// filled square. + LivePaintSelectFace, + /// Live Paint Selection over an edge of a Live Paint group that a click selects: the arrow with + /// a short line. + LivePaintSelectEdge, + /// Live Paint Selection where a click selects no face or edge: the arrow with an x. + LivePaintSelectOutside, /// The Measure tool. Measure, /// The Print Tiling tool. @@ -248,7 +256,8 @@ impl Cursor { } /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the - /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. + /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools', the Live + /// Paint tools') become a plain crosshair at the hotspot. pub fn precise(self) -> Self { match self { Cursor::Pen @@ -287,6 +296,9 @@ impl Cursor { | Cursor::LivePaintBucket | Cursor::LivePaintBucketFace | Cursor::LivePaintBucketEdge + | Cursor::LivePaintSelectFace + | Cursor::LivePaintSelectEdge + | Cursor::LivePaintSelectOutside | Cursor::Measure | Cursor::PrintTiling | Cursor::SymbolSprayer diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 107eb34aeb..6f5b32e56a 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -28,7 +28,7 @@ shown_when = [ "The Selection tool over empty canvas or over an unselected object (no Alt).", "Dragging with the Selection tool (a move), with Direct Selection (an anchor, a segment or a handle), or with a shape tool (from the selection's centre point).", "Any tool with no cursor of its own (Touch Type, Rotate View).", - "The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.", + "The resting state of Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.", ] hotspot = "The arrow's tip." look = "The default pointer: a solid arrow. ArrowHollow is its hollow twin." @@ -89,6 +89,39 @@ shown_when = [ hotspot = "The front arrowhead's tip." look = "Says \"a copy\": a double arrowhead, one in front of the other." +[[cursor]] +id = "LivePaintSelectFace" +group = "Selection" +shown_when = [ + "Live Paint Selection over a face of a Live Paint group that a click selects: inside the face, with Select Fills on, and without Alt unless Select Strokes is off.", + "From a press there until the release, wherever the drag goes.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The arrow's tip." +look = "Arrow with a small filled square badge (a face). LivePaintSelectEdge and LivePaintSelectOutside share the arrow." + +[[cursor]] +id = "LivePaintSelectEdge" +group = "Selection" +shown_when = [ + "Live Paint Selection within reach of an edge of a Live Paint group that a click selects: Select Strokes is on, and Alt is held or Select Fills is off.", + "From a press there until the release, wherever the drag goes.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The arrow's tip." +look = "Arrow with a short line badge (an edge)." + +[[cursor]] +id = "LivePaintSelectOutside" +group = "Selection" +shown_when = [ + "Live Paint Selection where a click selects no face or edge: away from Live Paint groups, over a group that is locked, hidden or outside the isolated art, inside a face with Alt held and no edge within reach, or with Select Fills and Select Strokes both off.", + "From a press there until the release, wherever the drag goes.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The arrow's tip." +look = "Arrow with a small x badge (nothing to select)." + [[cursor]] id = "Move" group = "Selection" diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 3addd48785..3030414b38 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -66,7 +66,7 @@ use crate::meshedit::{MeshEdit, grid_of}; use crate::pathtype::{self, BracketDrag, near_bracket}; use crate::rulerguide::GuideEdit; use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at, selection_bounds}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, Mods, Overlay, PixelOp, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Debug)] enum State { @@ -1465,11 +1465,11 @@ impl DirectSelectionTool { } out.push(Action::Begin("Move".into())); self.state = State::MoveAnchors { start, grab, began: true, segment }; - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.anchor_snap = Some(PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); self.line_end = line_end_leave(cx, grab); // A segment held by a point along it lands its anchors on the grid (the box round // them, an edge on a gridline on each axis), not that point (#3835). - let grid = segment && (cx.snap_to_grid || cx.snap_to_pixel); + let grid = segment && (cx.snap_to_grid || cx.snaps_to_pixel(PixelOp::Moving)); self.partial_grid = grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); } @@ -1505,8 +1505,8 @@ impl DirectSelectionTool { // them and the objects selected whole (`path.moveAnchors`), as an anchor drag // does, while Alt is up; the other anchors of a partly selected path stay in // place. The history names the move or the copy at the end. - self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); - let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel); + self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snaps_to_pixel(PixelOp::Moving)); self.partial_grid = grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); let copy = ev.mods.alt && self.partial_snap.is_none(); @@ -1514,8 +1514,8 @@ impl DirectSelectionTool { self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; if grab.is_some() { // Alt can go down or up at any time: gather both while nothing has moved. - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); - self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + self.anchor_snap = Some(PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.copy_snap = Some(PointSnap::moving(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); // As a move with the Selection tool does ([`MoveSnap::new`]). let back = cx.smart_guides && !cx.snap_to_grid && cx.snap_to_last_location; self.last_location = back.then(|| (Targets::last_location(cx), selection_bounds(cx))); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index 0ac443c731..7ce48bdc1e 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -634,6 +634,33 @@ fn snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor( assert!(labels.is_empty(), "{labels:?}"); } +/// Pixel Snapping Options › While Moving off: a direct-selected anchor dragged with Snap to Pixel +/// on keeps its fractional offset; with the option on it lands on whole pixels. +#[test] +fn an_anchor_drag_follows_the_while_moving_option() { + use crate::PixelSnap; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let s = anchors_of(id, &[(0, 0)]); + let p = paint(); + let moved = |pixel_snap: PixelSnap| { + let c = ToolContext { snap_to_pixel: true, pixel_snap, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.3, 100.4)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 106.3, 105.4)); + let Some(Action::Preview(command, v)) = a.last() else { panic!("{a:?}") }; + assert_eq!(command, "path.moveAnchors"); + Vec2::new(v["dx"].as_f64().unwrap_or(f64::NAN), v["dy"].as_f64().unwrap_or(f64::NAN)) + }; + let m = moved(PixelSnap { moving: false, ..PixelSnap::default() }); + assert!((m - Vec2::new(6.0, 5.0)).hypot() < 1e-9, "{m:?}"); + let m = moved(PixelSnap { drawing: false, scaling: false, moving: true }); + assert!((m - Vec2::new(5.7, 4.6)).hypot() < 1e-9, "{m:?}"); +} + /// A click (no drag) on an anchor that is selected with others, whether the whole path is or /// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the /// selection so a drag moves it all, the release without a drag narrows it. Shift still diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 123082aa25..f586650200 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -34,6 +34,11 @@ pub const MIN_FIDELITY: f64 = 0.01; /// Fidelity at the highest zoom (64000 %), whose screen pixels the tools send in points (#5729). pub const MIN_FIT_TOLERANCE: f64 = MIN_FIDELITY / 640.0; +/// The coarsest tolerance (points) `path.freehand` fits a Pencil or Paintbrush stroke at: the highest +/// Fidelity the tools and their Tool Options take (100 px) at the lowest zoom (3.13 %), about 3195 pt. +/// A change to either range changes this bound with it. +pub const MAX_FIT_TOLERANCE: f64 = 100.0 / 0.0313; + /// The lowest Fidelity (points) the Smooth tool takes. pub const SMOOTH_MIN_FIDELITY: f64 = 0.05; diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index e4811418c7..d426044379 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -42,6 +42,13 @@ const PREVIEW_FIDELITY_PX: f64 = 0.5; /// The Smooth tool's Fidelity until Smooth Tool Options sets another. const SMOOTH_FIDELITY: f64 = 2.5; +/// The `fidelity` (points) the Pencil and the Paintbrush send `path.freehand` for `px` screen pixels +/// at the current zoom, within the range the command takes +/// ([`super::MIN_FIT_TOLERANCE`]..[`super::MAX_FIT_TOLERANCE`]). +fn fit_tolerance(cx: &ToolContext, px: f64) -> f64 { + cx.tol(px).clamp(super::MIN_FIT_TOLERANCE, super::MAX_FIT_TOLERANCE) +} + pub struct GestureTool { /// How many points the last preview of the stroke had ([`crate::previews_at`]); 0 when it was /// fitted coarser than the stroke kept ([`PREVIEW_FIDELITY_PX`]). @@ -216,10 +223,10 @@ impl GestureTool { } /// Does a straight Knife cut snap to anchors and paths? Only with Smart Guides on, which Snap to - /// Pixel and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the Pencil's straight - /// line's do: the press, and the release unless Shift keeps it on a 45° step. + /// Pixel (for drawing) and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the + /// Pencil's straight line's do: the press, and the release unless Shift keeps it on a 45° step. fn knife_snaps(&self, cx: &ToolContext) -> bool { - self.id == "knife" && self.straight && cx.smart_guides && !cx.snap_to_pixel && !cx.snap_to_grid + self.id == "knife" && self.straight && cx.smart_guides && !cx.snaps_to_pixel(crate::PixelOp::Drawing) && !cx.snap_to_grid } /// Does the end of a straight line snap: the Pencil's Alt line's, or a straight Knife cut's @@ -460,7 +467,7 @@ impl GestureTool { pub fn freehand_params(&self, cx: &ToolContext, closed: bool) -> Value { let mut v = json!({ "points": self.stroke_json(), - "fidelity": cx.tol(self.fidelity), + "fidelity": fit_tolerance(cx, self.fidelity), "closed": closed, "style": if self.id == "paintbrush" { "brush" } else { "pencil" }, "fill": self.fill, @@ -654,7 +661,7 @@ impl Tool for GestureTool { if self.previews() && crate::previews_at(self.points.len()) { let mut v = self.freehand_params(cx, false); let fidelity = self.fidelity.max(PREVIEW_FIDELITY_PX); - v["fidelity"] = json!(cx.tol(fidelity)); + v["fidelity"] = json!(fit_tolerance(cx, fidelity)); // A preview fitted coarser than the stroke kept is stale (`deactivate`). self.previewed = if fidelity > self.fidelity { 0 } else { self.points.len() }; if !self.live_fit { diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 567f6aee1f..f367040611 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -215,6 +215,52 @@ fn fidelity_is_in_screen_pixels_at_any_zoom() { } } +/// The highest Fidelity (100 px) at the lowest zoom (3.13 %) is sent, while the stroke is drawn and +/// at its release, as [`crate::draw2::MAX_FIT_TOLERANCE`], the coarsest tolerance `path.freehand` +/// reads. +#[test] +fn highest_fidelity_at_the_lowest_zoom_sends_the_coarsest_tolerance() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.zoom = 0.0313; + let stroke: Vec<_> = [(0.0, 0.0), (40.0, 20.0), (80.0, 0.0), (120.0, 20.0)].iter().map(|&(x, y)| (x / cx.zoom, y / cx.zoom)).collect(); + for id in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(id); + t.set_option("fidelity", &json!(100.0)); + let acts = drag(&mut t, &cx, &stroke, Mods::default()); + let sent: Vec<_> = + acts.concat().into_iter().filter_map(|a| if let Action::Preview(_, v) = a { v["fidelity"].as_f64() } else { None }).collect(); + assert_eq!(sent, [crate::draw2::MAX_FIT_TOLERANCE; 4], "{id}"); + } +} + +/// `path.freehand` rejects a `fidelity` outside `MIN_FIT_TOLERANCE..=MAX_FIT_TOLERANCE`, so at a zoom +/// beyond the view's range the Pencil and the Paintbrush send the nearer end of it, while the stroke +/// is drawn and at its release. +#[test] +fn fidelity_sent_stays_within_what_path_freehand_takes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let (lo, hi) = (crate::draw2::MIN_FIT_TOLERANCE, crate::draw2::MAX_FIT_TOLERANCE); + for (zoom, fidelity, release) in [(0.01, 100.0, hi), (1000.0, 0.01, lo)] { + cx.zoom = zoom; + let stroke: Vec<_> = [(0.0, 0.0), (40.0, 20.0), (80.0, 0.0), (120.0, 20.0)].iter().map(|&(x, y)| (x / zoom, y / zoom)).collect(); + for id in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(id); + t.set_option("fidelity", &json!(fidelity)); + let acts = drag(&mut t, &cx, &stroke, Mods::default()); + let sent: Vec<_> = + acts.concat().into_iter().filter_map(|a| if let Action::Preview(_, v) = a { v["fidelity"].as_f64() } else { None }).collect(); + assert!(sent.iter().all(|f| (lo..=hi).contains(f)), "{id} at {zoom}x: {sent:?}"); + assert_eq!(sent.last(), Some(&release), "{id} at {zoom}x: {sent:?}"); + } + } +} + /// The Blob Brush's Fidelity stays in points (#5729 is the Pencil's and the Paintbrush's): its /// stroke, and another tool chosen while it is drawn, send it as set at any zoom. #[test] diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 6f0b50d4b5..006f498b6e 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -3,6 +3,7 @@ use super::{ Affine, GlyphSnap, GuideStyle, Handle, HitOptions, Line, NodeId, OrientedBox, Overlay, ParamCurve, ParamCurveDeriv, ParamCurveNearest, PathSeg, Point, Rect, ScreenFrame, ToolContext, Vec2, constrain_angle_from, hit_test, }; +use crate::PixelOp; /// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back /// settles where they meet (none when they don't nearby). @@ -100,11 +101,29 @@ pub struct PointSnap { /// The smart guide targets, or with Smart Guides off those of Snap to Point, and how near (in /// screen pixels) they pull; none with pixels or the grid snapping instead, or nothing on. targets: Option<(Targets, f64)>, + /// The kind of edit the point serves: whether Snap to Pixel rounds it + /// ([`ToolContext::snaps_to_pixel`]). + op: PixelOp, } impl PointSnap { + /// Snapping for a drawn point. pub fn new(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { - let targets = if cx.snap_to_pixel || cx.snap_to_grid { + Self::for_op(cx, PixelOp::Drawing, targets) + } + + /// Snapping for a moved point (an anchor, or the selection held by one). + pub fn moving(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { + Self::for_op(cx, PixelOp::Moving, targets) + } + + /// Snapping for a dragged handle that scales ([`Self::snap_scale`]). + pub fn scaling(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self { + Self::for_op(cx, PixelOp::Scaling, targets) + } + + fn for_op(cx: &ToolContext, op: PixelOp, targets: impl FnOnce() -> Targets) -> Self { + let targets = if cx.snaps_to_pixel(op) || cx.snap_to_grid { None } else if cx.smart_guides { // Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812). @@ -116,7 +135,7 @@ impl PointSnap { } else { None }; - Self { targets } + Self { targets, op } } /// Snapping for a point placed anywhere, as a drawing tool's ([`DrawSnap`]) is: the art in the @@ -135,7 +154,7 @@ impl PointSnap { /// showing why. Pixels and the grid round it before Shift turns it onto its angle. pub fn snap_from(&self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> (Point, Vec) { let keep = |q: Point| from.and_then(|f| f.constrained(q)).unwrap_or(q); - if cx.snap_to_pixel { + if cx.snaps_to_pixel(self.op) { return (keep(Point::new(p.x.round(), p.y.round())), vec![]); } if cx.snap_to_grid { @@ -148,9 +167,10 @@ impl PointSnap { } /// Snap a bounding-box resize ([`Targets::snap_scale`]) as [`Self::snap`] snaps a point. With - /// Snap to Pixel on, the corner or side the handle moves lines up with whole pixels, and with - /// Snap to Grid on it lines up with the nearest gridline, however far that is; neither shows - /// guides. Otherwise the handle snaps to the targets [`Self::new`] gathered. + /// Snap to Pixel on for scaling, the corner or side the handle moves lines up with whole + /// pixels, and with Snap to Grid on it lines up with the nearest gridline, however far that + /// is; neither shows guides. Otherwise the handle snaps to the targets [`Self::scaling`] + /// gathered. pub fn snap_scale( &self, cx: &ToolContext, @@ -160,7 +180,7 @@ impl PointSnap { proportional: bool, from_center: bool, ) -> (Affine, Vec) { - let step = if cx.snap_to_pixel { + let step = if cx.snaps_to_pixel(PixelOp::Scaling) { Some(1.0) } else if cx.snap_to_grid { Some(cx.grid_step()).filter(|s| s.is_finite() && *s > 0.0) @@ -327,7 +347,15 @@ impl SnapKey { revision: cx.revision, exclude: exclude.to_vec(), screen: cx.screen, - view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular], + view: [ + cx.smart_guides, + cx.snap_to_point, + cx.snaps_to_pixel(PixelOp::Drawing), + cx.snap_to_grid, + cx.guides, + cx.snap_tangent, + cx.snap_perpendicular, + ], snap_to_glyph: (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap), style: GuideStyle::of(cx), tolerances: [cx.snapping_tolerance, cx.snap_tolerance], @@ -381,7 +409,7 @@ impl DrawSnap { /// Guides snapping tolerance (the Knife's straight cut). The targets serve this press only. pub fn press_on_paths(&mut self, cx: &ToolContext, p: Point) -> Point { let targets = Targets::for_drawing(cx.doc, &[], visible(cx), None).styled(cx).anchors_and_paths(); - self.targets = Some((None, PointSnap { targets: Some((targets, cx.snapping_tolerance)) })); + self.targets = Some((None, PointSnap { targets: Some((targets, cx.snapping_tolerance)), op: PixelOp::Drawing })); self.snap(cx, p, None) } diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 41b9f517ee..cb0fdfc1dc 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -168,13 +168,13 @@ impl Targets { /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). With Smart /// Guides › Snap to Last Location, a move (not a copy, which has nothing in `exclude`) goes back - /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel is on. + /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel snaps moves. pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { let mut t = Self::collect(cx.doc, exclude, None).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } - t.last_location = cx.snap_to_last_location && !cx.snap_to_pixel && !exclude.is_empty(); + t.last_location = cx.snap_to_last_location && !cx.snaps_to_pixel(crate::PixelOp::Moving) && !exclude.is_empty(); t } @@ -189,9 +189,9 @@ impl Targets { /// Targets that hold nothing but Smart Guides › Snap to Last Location ([`Self::snap_back`]), /// styled as `cx` asks: for a move of the selection whose other snaps come from other targets, /// such as paths selected whole that Direct Selection moves held by an anchor. Snap to Pixel - /// turns it off, as it does every Smart Guides snap. + /// snapping moves turns it off, as it does every Smart Guides snap. pub fn last_location(cx: &ToolContext) -> Self { - Self { last_location: cx.snap_to_last_location && !cx.snap_to_pixel, ..Self::default() }.styled(cx) + Self { last_location: cx.snap_to_last_location && !cx.snaps_to_pixel(crate::PixelOp::Moving), ..Self::default() }.styled(cx) } /// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides › diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index baf461c6c7..ee752dd968 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -58,7 +58,7 @@ mod context; mod cursor; mod input; -pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, ScreenFrame, ToolContext}; +pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, PixelOp, PixelSnap, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; @@ -542,6 +542,7 @@ pub(crate) mod testutil { snap_to_grid: false, show_bbox: true, snap_to_pixel: false, + pixel_snap: crate::PixelSnap::default(), snap_to_point: true, snap_tangent: true, snap_perpendicular: true, diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 095e0d21cf..c7a205a19e 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -76,7 +76,7 @@ pub fn gradient_params(g: &GradientPaint) -> Value { v["dither"] = json!(true); } if !g.gradient.method.is_classic() { - v["method"] = json!(g.gradient.method.label().to_lowercase()); + v["method"] = json!(g.gradient.method.id()); } // A freeform gradient has no ends to spread past (`paint.setFill` refuses a spread for one). if g.gradient.is_spread() { diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index a90770d3fb..8e037443cb 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; use crate::guides::{PointSnap, Targets}; -use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; +use crate::{Action, Cursor, Overlay, PixelOp, PointerEvent, PointerKind, ToolContext}; /// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection /// tolerance (the newest of equals). @@ -123,7 +123,7 @@ struct GuideSnap { impl GuideSnap { fn new(cx: &ToolContext) -> Self { let targets = || Targets::collect(cx.doc, &[], None); - let targets = if cx.snap_to_pixel || cx.snap_to_grid { + let targets = if cx.snaps_to_pixel(PixelOp::Moving) || cx.snap_to_grid { None } else if cx.smart_guides { Some((targets().styled(cx), cx.snapping_tolerance)) @@ -136,7 +136,8 @@ impl GuideSnap { } /// Where a guide dragged to `v` (the x of a vertical one, the y of a horizontal one) goes: - /// with Shift on the nearest ruler tick, else on whole pixels, the grid or a target. + /// with Shift on the nearest ruler tick, else on whole pixels (Snap to Pixel's While Moving + /// option), the grid or a target. fn snap(&mut self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> f64 { self.snapped.clear(); if shift { @@ -144,7 +145,7 @@ impl GuideSnap { let origin = if vertical { origin.x } else { origin.y }; return origin + cx.unit.snap_to_ruler_tick(v - origin, cx.zoom); } - if cx.snap_to_pixel { + if cx.snaps_to_pixel(PixelOp::Moving) { return v.round(); } if cx.snap_to_grid { diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs index f80f31c776..b4b7c256d3 100644 --- a/crates/tools/src/rulerguide/tests.rs +++ b/crates/tools/src/rulerguide/tests.rs @@ -253,6 +253,24 @@ fn new_ruler_guide_alt_snaps_on_the_effective_axis() { } } +/// A guide dragged out of a ruler snaps to whole pixels with Snap to Pixel only while the Pixel +/// Snapping Options' While Moving is on; While Drawing and While Scaling do not change it. +#[test] +fn new_ruler_guide_follows_the_while_moving_option() { + use crate::PixelSnap; + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let off = ToolContext { smart_guides: false, snap_to_point: false, snap_to_pixel: true, ..cx(&d, &s, &p) }; + for (pixel_snap, expected) in + [(PixelSnap { moving: false, ..PixelSnap::default() }, 226.4), (PixelSnap { drawing: false, scaling: false, moving: true }, 226.0)] + { + let c = ToolContext { pixel_snap, ..off }; + let mut g = NewGuide::new(&c, true, None); + let actions = g.pointer(&c, &ev(PointerKind::Drag, 226.4, 237.6, Mods::default()), true); + assert_eq!(actions.last(), Some(&Action::Preview("guide.add".into(), json!({"vertical": true, "pos": expected}))), "{pixel_snap:?}"); + } +} + /// With Smart Guides off, Snap to Point pulls a dragged guide onto the anchors (within its /// distance), not the edges' midpoints. #[test] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 05b81ae049..70060ebc2b 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -56,7 +56,7 @@ use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; use crate::shaperwidget; use crate::typewidget; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; +use crate::{Action, Cursor, Mods, Overlay, PixelOp, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; /// Direction is read on screen, even in a turned view. Within three pixels of the press's /// horizontal position, retain the previous rule so hand jitter does not flip it. @@ -228,8 +228,9 @@ fn distance_overlays(cx: &ToolContext, to: DistanceTo) -> Vec { /// Snap to Grid lands its edges on the grid (#740), taking over from the rest; otherwise Smart /// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the /// point it was grabbed by (an anchor pressed near, else the pressed point) on an anchor or a ruler -/// guide. Snap to Pixel puts its top-left on whole pixels. A copy (Alt, which can go down or up at -/// any time during the drag) leaves the original behind, so it snaps to the original too. +/// guide. Snap to Pixel puts its top-left on whole pixels while its While Moving option is on. A +/// copy (Alt, which can go down or up at any time during the drag) leaves the original behind, so +/// it snaps to the original too. pub(crate) struct MoveSnap { bounds: Option, targets: Option, @@ -314,7 +315,7 @@ impl MoveSnap { } } } - if cx.snap_to_pixel + if cx.snaps_to_pixel(PixelOp::Moving) && let Some(b) = self.bounds { d = Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0); @@ -388,7 +389,7 @@ impl MoveSnap { guides = ov; } } - if cx.snap_to_pixel + if cx.snaps_to_pixel(PixelOp::Moving) && let Some(b) = self.bounds { let at = Point::new(b.x0, b.y0) + d; @@ -1655,7 +1656,7 @@ impl SelectionTool { // What the handle moves snaps as a dragged point does: to pixels or the // grid, else with Smart Guides to the other objects, else with Snap to // Point to their anchors; the ruler guides with Snap to Point. - self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx))); + self.targets = Some(PointSnap::scaling(cx, || Targets::for_scale(cx))); self.last_backquote = false; self.last_box_pos = None; self.state = State::Scaling { handle, bx, areas }; diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index c41056b7aa..63ca697c7d 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -1794,6 +1794,51 @@ fn handle_drag_lands_what_it_moves_on_whole_pixels() { assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}"); } +/// Pixel Snapping Options › While Scaling off: a handle drag keeps the fractional size it is +/// dragged to with Snap to Pixel on, and turned back on, the drag lands on whole pixels again; While +/// Drawing and While Moving do not change it. +#[test] +fn handle_drag_follows_the_while_scaling_option() { + use crate::PixelSnap; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let drag = |pixel_snap: PixelSnap| { + let c = ToolContext { snap_to_pixel: true, pixel_snap, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0)); + scaled(&t.pointer(&c, &ev(PointerKind::Drag, 212.4, 230.7))) + }; + let r = drag(PixelSnap { scaling: false, ..PixelSnap::default() }); + assert!(near(r, Rect::new(100.0, 100.0, 212.4, 230.7)), "{r:?}"); + let r = drag(PixelSnap { drawing: false, moving: false, scaling: true }); + assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}"); +} + +/// Pixel Snapping Options › While Moving off: a move keeps its fractional offset with Snap to +/// Pixel on, and Smart Guides › Snap to Last Location works again for it. +#[test] +fn move_follows_the_while_moving_option() { + use crate::PixelSnap; + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let pixel_snap = PixelSnap { moving: false, ..PixelSnap::default() }; + let c = ToolContext { snap_to_pixel: true, pixel_snap, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) }; + let snap = MoveSnap::new(&c); + let (dv, _) = snap.snap(&c, Point::new(150.0, 150.0), Vec2::new(3.3, 4.6), false); + assert!((dv - Vec2::new(3.3, 4.6)).hypot() < 1e-9, "{dv:?}"); + let on = ToolContext { pixel_snap: PixelSnap::default(), ..c }; + let (dv, _) = MoveSnap::new(&on).snap(&on, Point::new(150.0, 150.0), Vec2::new(3.3, 4.6), false); + assert!((dv - Vec2::new(3.0, 5.0)).hypot() < 1e-9, "{dv:?}"); + let back = ToolContext { smart_guides: true, snap_to_last_location: true, snapping_tolerance: 10.0, ..c }; + assert!(Targets::for_move(&back, &back.selection.objects).snap_back(None, Vec2::new(1.0, 1.0), back.snap_tol()).is_some()); + let pixels = ToolContext { pixel_snap: PixelSnap::default(), ..back }; + assert!(Targets::for_move(&pixels, &pixels.selection.objects).snap_back(None, Vec2::new(1.0, 1.0), pixels.snap_tol()).is_none()); +} + /// #2054: with Smart Guides off, View › Snap to Point lands a dragged handle on another /// object's anchor or a ruler guide within its distance (2 px); with Smart Guides on, the /// ruler guides still pull it within the Snapping Tolerance (4 px), as a move does (#1534). diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index fb291796f7..66728b4826 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -236,7 +236,7 @@ impl ShapeTool { self.selection = if let Some((handle, bx)) = self.over_handle(cx, ev.pos) { let is_area = |id: &vectorcraft_doc::NodeId| cx.doc.node(*id).is_some_and(is_area_type); let areas = cx.selection.objects.iter().any(is_area); - Some(SelectionDrag::Scale { handle, bx, areas, snap: Box::new(PointSnap::new(cx, || Targets::for_scale(cx))), last: None }) + Some(SelectionDrag::Scale { handle, bx, areas, snap: Box::new(PointSnap::scaling(cx, || Targets::for_scale(cx))), last: None }) } else { match hit_center(cx, ev.pos, cx.point_tol()) { Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }), diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 69bbf9051f..0f804a8ef6 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -161,7 +161,7 @@ struct ResizeSnap { impl ResizeSnap { fn new(cx: &ToolContext) -> Self { - Self { art: PointSnap::new(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) } + Self { art: PointSnap::scaling(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) } } /// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle` as [`snap`] snaps diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 4ffd435af6..5af642c44b 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -37,7 +37,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; use crate::bbox::{Handle, hit_handle, scale_for_drag}; use crate::guides::{DrawSnap, Leave, Targets}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, Mods, Overlay, PixelOp, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug)] enum Drag { @@ -205,11 +205,12 @@ impl ArtboardTool { self.copy_targets = cx.smart_guides.then(|| Targets::for_artboard(cx.doc, usize::MAX, &[]).styled(cx)); } - /// Snap a dragged handle: to whole pixels, the grid, or Smart Guides (in that order, as when - /// drawing). With Smart Guides the bleed edge `bleed` beyond the handle may snap instead. + /// Snap a dragged handle: to whole pixels (Snap to Pixel's While Scaling option), the grid, or + /// Smart Guides (in that order, as when drawing). With Smart Guides the bleed edge `bleed` + /// beyond the handle may snap instead. fn snap_handle(&mut self, cx: &ToolContext, p: Point, bleed: Vec2) -> Point { self.guides.clear(); - if cx.snap_to_pixel { + if cx.snaps_to_pixel(PixelOp::Scaling) { return Point::new(p.x.round(), p.y.round()); } if cx.snap_to_grid { @@ -222,12 +223,13 @@ impl ArtboardTool { q } - /// Snap a move (or an Alt-drag `copy`) of `rect` by `d`: its top-left to whole pixels or the - /// grid, or any of its edges and centre to Smart Guides. Returns the snapped delta. + /// Snap a move (or an Alt-drag `copy`) of `rect` by `d`: its top-left to whole pixels (Snap to + /// Pixel's While Moving option) or the grid, or any of its edges and centre to Smart Guides. + /// Returns the snapped delta. fn snap_move(&mut self, cx: &ToolContext, rect: Rect, d: Vec2, copy: bool) -> Vec2 { self.guides.clear(); let tl = Point::new(rect.x0, rect.y0); - if cx.snap_to_pixel { + if cx.snaps_to_pixel(PixelOp::Moving) { return Vec2::new((tl.x + d.x).round() - tl.x, (tl.y + d.y).round() - tl.y); } if cx.snap_to_grid { diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index bd7c0c0ecd..1583584940 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -4,8 +4,9 @@ //! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle //! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or //! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the -//! box and preview `object.distort`. After a rotation the box stays turned with the selection, as -//! the Selection tool's does: its handles scale, shear and distort along the selection's own sides. +//! box and preview `object.distort`; in Free Distort, Shift or the Constrain option keeps a dragged +//! corner on the diagonal through the opposite corner. After a rotation the box stays turned with +//! the selection, as the Selection tool's does: its handles scale, shear and distort along the selection's own sides. use serde_json::{Value, json}; use vectorcraft_doc::OrientedBox; @@ -123,6 +124,18 @@ fn corner_index(h: Handle) -> Option { } } +/// The part of drag `d` that lies along the diagonal from corner `h` of `r` to the opposite +/// corner. A Free Distort corner dragged with Shift or Constrain moves only along that line, so its +/// offset from the opposite corner keeps the box's width-to-height ratio. A box with no width and no height gives no movement. +pub fn along_diagonal(r: Rect, h: Handle, d: Vec2) -> Vec2 { + let u = h.pos(r) - h.opposite().pos(r); + let len2 = u.hypot2(); + if len2 <= 1e-18 || !len2.is_finite() { + return Vec2::ZERO; + } + u * (d.dot(u) / len2) +} + /// The distorted quad for dragging handle `h` of `r` by `d`. pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4] { let mut q = rect_corners(r); @@ -194,9 +207,13 @@ impl FreeTransformTool { self.guides.clear(); let xf = match d.op { Op::Corners(h, mode) => { + // Shift or Constrain keeps a Free Distort corner on its diagonal. + let constrained = shift && mode == DistortMode::Distort && h.is_corner(); + let local = if constrained { along_diagonal(bx.rect, h, local) } else { local }; let q = distort_quad(bx.rect, h, local, mode).map(|c| bx.to_doc() * c); self.quad = Some(q); - self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(delta.x, delta.y))); + let moved = if constrained { bx.to_doc() * (h.pos(bx.rect) + local) - bx.to_doc() * h.pos(bx.rect) } else { delta }; + self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(moved.x, moved.y))); let corners: Vec = q.iter().map(|c| json!([c.x, c.y])).collect(); let r = bx.rect; let mut params = json!({ "corners": corners, "from": [r.x0, r.y0, r.x1, r.y1] }); @@ -248,7 +265,7 @@ impl Tool for FreeTransformTool { self.drag = None; let Some(bx) = selection_box(cx) else { return vec![] }; if let Some(op) = self.classify(cx, &bx, p, ev.mods) { - self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::for_scale(cx))); + self.targets = op.handle().is_some().then(|| PointSnap::scaling(cx, || Targets::for_scale(cx))); self.drag = Some(Drag { op, bx, start: p, began: false }); } vec![] @@ -571,6 +588,123 @@ mod tests { assert_eq!(q[2], Point::new(10.0, 10.0)); } + /// The corners an `object.distort` preview sends. + fn sent_corners(a: &[Action]) -> Vec { + let Some(Action::Preview(c, v)) = a.last() else { panic!("{a:?}") }; + assert_eq!(c, "object.distort"); + v["corners"].as_array().unwrap().iter().map(|p| Point::new(p[0].as_f64().unwrap(), p[1].as_f64().unwrap())).collect() + } + + fn assert_corners(got: &[Point], want: [(f64, f64); 4]) { + for (g, w) in got.iter().zip(want) { + assert!(g.distance(Point::new(w.0, w.1)) < 1e-9, "{got:?} != {want:?}"); + } + } + + /// A 200 × 100 rectangle at (100, 100)–(300, 200), selected. + fn wide_rect() -> (vectorcraft_doc::Document, Selection, vectorcraft_doc::NodeId) { + let (mut d, id) = doc_with_rect(); + let wide = Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(2.0, 1.0) * Affine::translate((-100.0, -100.0)); + d.node_mut(id).unwrap().transform(wide, false); + let mut s = Selection::default(); + s.add(id); + (d, s, id) + } + + /// In Free Distort, Shift or the Constrain option keeps the dragged corner on the diagonal + /// through the opposite corner; the other three corners stay put. + #[test] + fn free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain() { + let (d, s, _) = wide_rect(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + // The bottom-right corner dragged by (40, 60): along the diagonal (200, 100) that is + // (40·200 + 60·100) / (200² + 100²) = 0.28 of it, (56, 28). + let fixed = [(100.0, 100.0), (300.0, 100.0), (356.0, 228.0), (100.0, 200.0)]; + let drag = |t: &mut FreeTransformTool, m: Mods| { + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0).with_mods(m)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0).with_mods(m)); + t.pointer(&cx, &ev(PointerKind::Up, 340.0, 260.0).with_mods(m)); + sent_corners(&a) + }; + let mut t = FreeTransformTool::default(); + t.set_option("mode", &json!("distort")); + assert_corners(&drag(&mut t, Mods::default()), [(100.0, 100.0), (300.0, 100.0), (340.0, 260.0), (100.0, 200.0)]); + assert_corners(&drag(&mut t, shift), fixed); + t.set_option("constrain", &json!(true)); + assert_corners(&drag(&mut t, Mods::default()), fixed); + // Cmd+Shift on a corner in the Free Transform mode is Free Distort, constrained too. + let mut t = FreeTransformTool::default(); + assert_corners(&drag(&mut t, Mods { cmd: true, shift: true, ..Default::default() }), fixed); + // Cmd+Alt+Shift stays Perspective Distort, with Constrain on as well. + t.set_option("constrain", &json!(true)); + let perspective = Mods { cmd: true, alt: true, shift: true, ..Default::default() }; + assert_corners(&drag(&mut t, perspective), [(100.0, 100.0), (300.0, 40.0), (300.0, 260.0), (100.0, 200.0)]); + // The measurement label shows how far the corner moved. + let cx = ToolContext { measurement_labels: true, ..cx }; + let mut t = FreeTransformTool::default(); + t.set_option("mode", &json!("distort")); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0).with_mods(shift)); + let want = cx.offset_label(56.0, 28.0); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want)), "{want}"); + } + + /// Shift pressed in the middle of a Free Distort corner drag constrains it from then on, in the + /// same undo step; letting go frees the corner again. + #[test] + fn shift_pressed_during_a_free_distort_drag_constrains_it() { + let (d, s, _) = wide_rect(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = FreeTransformTool::default(); + t.set_option("mode", &json!("distort")); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 310.0, 210.0)); + assert_eq!(a[0], Action::Begin("Distort".into())); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0).with_mods(Mods { shift: true, ..Default::default() })); + assert_eq!(a.len(), 1, "no new undo step: {a:?}"); + assert_corners(&sent_corners(&a), [(100.0, 100.0), (300.0, 100.0), (356.0, 228.0), (100.0, 200.0)]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0)); + assert_corners(&sent_corners(&a), [(100.0, 100.0), (300.0, 100.0), (340.0, 260.0), (100.0, 200.0)]); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 340.0, 260.0)), vec![Action::Commit]); + } + + /// A turned box is constrained along its own diagonal. + #[test] + fn a_turned_box_is_constrained_along_its_own_diagonal() { + let (mut d, s, id) = wide_rect(); + let c = Vec2::new(200.0, 150.0); + let turn = Affine::translate(c) * Affine::rotate(-30f64.to_radians()) * Affine::translate(-c); + d.node_mut(id).unwrap().transform(turn, false); + let p = paint(); + let cx = cx(&d, &s, &p); + let bx = selection_box(&cx).unwrap(); + assert!((bx.rect.width() - 200.0).abs() < 1e-9 && (bx.rect.height() - 100.0).abs() < 1e-9, "{bx:?}"); + let corner = Handle::BottomRight.pos(bx.rect); + let (from, to) = (bx.to_doc() * corner, bx.to_doc() * (corner + Vec2::new(40.0, 60.0))); + let mut t = FreeTransformTool::default(); + t.set_option("mode", &json!("distort")); + t.set_option("constrain", &json!(true)); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let got = sent_corners(&t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y))); + let want = bx.to_doc() * (corner + Vec2::new(56.0, 28.0)); + assert!(got[2].distance(want) < 1e-9, "{got:?} {want:?}"); + for (i, k) in [(0, Handle::TopLeft), (1, Handle::TopRight), (3, Handle::BottomLeft)] { + assert!(got[i].distance(bx.to_doc() * k.pos(bx.rect)) < 1e-9, "{got:?}"); + } + } + + /// A box with no size has no diagonal to move along; a line keeps to its own length. + #[test] + fn along_diagonal_of_a_degenerate_box() { + assert_eq!(along_diagonal(Rect::new(5.0, 5.0, 5.0, 5.0), Handle::BottomRight, Vec2::new(3.0, 4.0)), Vec2::ZERO); + assert_eq!(along_diagonal(Rect::new(5.0, 0.0, 5.0, 10.0), Handle::BottomRight, Vec2::new(3.0, 4.0)), Vec2::new(0.0, 4.0)); + let d = along_diagonal(Rect::new(0.0, 0.0, 10.0, 10.0), Handle::TopLeft, Vec2::new(1.0, 0.0)); + assert!(d.x.is_finite() && (d - Vec2::new(0.5, 0.5)).hypot() < 1e-12, "{d:?}"); + } + #[test] fn distort_drag_shows_move_measurement_labels() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index cccb1ebcae..0b575c503c 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -43,7 +43,7 @@ pub(crate) fn end_point(cx: &ToolContext, snap: &mut DrawSnap, a: Point, p: Poin return a; } let u = d / len; - let to_point = cx.snap_to_point && !(cx.smart_guides || cx.snap_to_grid || cx.snap_to_pixel); + let to_point = cx.snap_to_point && !(cx.smart_guides || cx.snap_to_grid || cx.snaps_to_pixel(crate::PixelOp::Drawing)); let s = if to_point { snap.measure(cx, p, None) } else { p }; let q = a + u * (s - a).dot(u); let leave = crate::guides::Leave::segment(cx, a, true); diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 5cabbed6b8..263c0ea5e4 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -238,8 +238,11 @@ impl Tool for TransformTool { return out; } d.began = true; - // The selection moves, so only the other objects are targets. - self.snap = Some(PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None))); + // The selection moves, so only the other objects are targets. The Scale tool's + // drag scales; the others move the art about the reference point. + let targets = || Targets::collect(cx.doc, &cx.selection.objects, None); + self.snap = + Some(if self.kind == TransformKind::Scale { PointSnap::scaling(cx, targets) } else { PointSnap::moving(cx, targets) }); out.push(Action::Begin(self.kind.label().into())); } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); diff --git a/crates/trace/src/subpixel.rs b/crates/trace/src/subpixel.rs index 550fb87d2f..5dba6f8850 100644 --- a/crates/trace/src/subpixel.rs +++ b/crates/trace/src/subpixel.rs @@ -12,6 +12,8 @@ //! An image with hard edges (a GIF, a 1-bit scan) says nothing more than the cracks do: every crack //! moves by the same amount. There the staircase is read the classic way: a one-pixel step between //! two runs is a point of a sloped edge (its middle is on the edge, the corners beside it are not). +//! A staircase of longer steps is read the same way where a smooth area was split into bands (see +//! [`outline`]). use kurbo::Vec2; use vectorcraft_geom::Point; @@ -119,6 +121,10 @@ pub(crate) fn low_contrast(steps: &[[f64; 5]]) -> bool { /// flat (see [`low_contrast`]). const FLAT: f64 = 12.0; +/// The longest step (px) of a staircase read as the slope through it in a smooth area (see +/// [`outline`]). An edge longer than this keeps its corners. +const STAIR_MAX: usize = 8; + /// What `across` says of a crack that is an edge whatever the image does there: a crack on the /// image's border, or one that the image's pixels cannot be read across (see [`low_contrast`]). pub(crate) const EDGE_STEP: [f64; 5] = [1.0, 1.0, f64::NAN, f64::NAN, f64::NAN]; @@ -233,6 +239,50 @@ pub(crate) fn outline( if junctions[edges[i % n].first].is_some() { 0 } else { (a.0 * b.1 - a.1 * b.0).signum() } }; let step: Vec = (0..n).map(|i| edges[i].len == 1 && turn(i) * turn(i + 1) < 0).collect(); + // A staircase of longer steps, where a smooth area was split into bands: a run of three or more + // edges of one to `STAIR_MAX` pixels, each turning one way where it starts and the other where + // it ends, with no junction on it, whose cracks read as the contour of a smooth area (see + // `low_contrast`, applied to the run's own cracks; a run is cut at any step whose own cracks + // show half the step between the colors or more). Its edges are steps: no corner where two of + // them meet, and each gives one point, its middle. Where a run meets an edge that is not a + // step, that corner stays. A sharp edge drawn as a staircase keeps its corners. (The runs are + // the same from either side: they depend only on the edges, their turns and the junctions, + // which every region around a pixel corner shares.) + let stair = { + let candidate: Vec = (0..n) + .map(|i| { + let e = &edges[i]; + (1..=STAIR_MAX).contains(&e.len) && turn(i) * turn(i + 1) < 0 && !(0..=e.len).any(|k| junction((e.first + k) % cracks)) + }) + .collect(); + let own = |i: usize| (0..edges[i].len).filter_map(|j| steps.get(edges[i].first + j).copied()).collect::>(); + // Whether a step's own cracks show under half of the step between the two colors, as a + // contour does (an edge shows nearly all of it): a staircase that runs on from a contour + // into an edge is cut there. (Summed in sorted order: the same from either side.) + let gentle = |i: usize| { + let mut near: Vec = own(i).iter().map(|s| s[0]).filter(|s| !s.is_nan()).collect(); + near.sort_by(f64::total_cmp); + !near.is_empty() && near.iter().sum::() < 0.5 * near.len() as f64 + }; + let mut stair = vec![false; n]; + // A loop turns once round in all, so some edge is not a candidate: runs start after it. + if let Some(s0) = (0..n).find(|&i| !candidate[i]) { + let mut k = 1; + while k < n { + let run: Vec = (k..n).map(|j| (s0 + j) % n).take_while(|&i| candidate[i] && gentle(i)).collect(); + k += run.len().max(1); + if run.len() < 3 { + continue; + } + if low_contrast(&run.iter().flat_map(|&i| own(i)).collect::>()) { + for &i in &run { + stair[i] = true; + } + } + } + } + stair + }; let outward = |d: (i32, i32)| Vec2::new(f64::from(d.1), f64::from(-d.0)); // The pixel at a corner of the shape lies on both of the corner's edges, and what it lacks of // the inside's colour is the doing of both: read as one edge's alone it dents the corner. The @@ -242,7 +292,7 @@ pub(crate) fn outline( let mut shifts = shifts; for i in 0..n { let (b, e) = ((i + n - 1) % n, i); - if step[b] || step[e] || turn(i) == 0 { + if step[b] || step[e] || (stair[b] && stair[e]) || turn(i) == 0 { continue; } let (before, edge) = (&edges[b], &edges[e]); @@ -276,6 +326,9 @@ pub(crate) fn outline( && edges[b].len >= 3 && edges[s1].dir == edges[b].dir && edges[s2].dir == edges[a].dir + // The edges of a staircase of longer steps (see `stair`) make no corner here. + && !stair[a] + && !stair[b] && free(s1) && free(s2) && free(b) @@ -311,7 +364,7 @@ pub(crate) fn outline( if k == 0 && first.is_some() { // The junction itself: the one point every region around it has. pts.push(junctions[crack].unwrap_or(corner)); - } else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] { + } else if along_edge == 0 && !step[i] && !step[(i + n - 1) % n] && !(stair[i] && stair[(i + n - 1) % n]) { // A true corner: where its two edges, each moved by its own shift, meet. let before = &edges[(i + n - 1) % n]; pts.push(corner + outward(before.dir) * shifts[(crack + cracks - 1) % cracks] + outward(e.dir) * shifts[crack]); @@ -319,6 +372,13 @@ pub(crate) fn outline( if !hard && let Some(sharp) = notch[i] { // A corner whose own pixel went to the other side: the corner, not the two steps. pts.extend(sharp); + } else if stair[i] { + if along_edge == e.len / 2 { + // The middle of the step, moved by its cracks' mean shift (multiples of 1/1024, + // so the same sum from either side). + let shift = (0..e.len).map(|j| shifts[e.first + j]).sum::() / e.len as f64; + pts.push(at(i) + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * (e.len as f64 / 2.0) + outward(e.dir) * shift); + } } else if step[i] || !hard { pts.push(corner + Vec2::new(f64::from(e.dir.0), f64::from(e.dir.1)) * 0.5 + outward(e.dir) * shifts[crack]); } @@ -494,6 +554,218 @@ mod tests { } } + #[test] + fn a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps() { + // Left and right regions meet along a staircase of steps two pixels across and three down + // (rows 0 to 8), a straight run of ten rows, and the same staircase again (rows 19 to 27). + // The cracks above row 9 read as the contour of a smooth area, the others as an edge, so + // the piece as a whole is no contour. + let (w, h) = (18, 28); + let edge = |y: usize| match y { + 0..9 => 2 + 2 * (y / 3), + 9..19 => 8, + _ => 10 + 2 * ((y - 19) / 3), + }; + let left: Vec = (0..w * h).map(|i| i % w < edge(i / w)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let step = |c: Crack| if c.at(0).unwrap().min(c.at(1).unwrap()) / w < 9 { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] }; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + assert!(!ours.contour); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + // The upper staircase: the middle of each of its steps, on one line, and none of its corners. + let middles = [(3.0, 3.0), (4.0, 4.5), (5.0, 6.0), (6.0, 7.5), (7.0, 9.0)].map(Point::from); + assert!(middles.iter().all(|m| pts.contains(m)), "{pts:?}"); + assert!([(4.0, 3.0), (4.0, 6.0), (6.0, 6.0), (6.0, 9.0)].map(Point::from).iter().all(|c| !pts.contains(c)), "{pts:?}"); + // The straight run keeps the corners at both its ends, so it stays straight. + assert!([(8.0, 9.0), (8.0, 19.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + // The lower one is a sharp edge: it keeps its corners. + assert!([(8.0, 19.0), (10.0, 19.0), (10.0, 22.0), (12.0, 22.0), (12.0, 25.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + + // Steps longer than STAIR_MAX keep their corners, in a smooth area too. + let (w, h) = (40, 40); + let left: Vec = (0..w * h).map(|i| i % w < 5 + 10 * (i / w / 10)).collect(); + let pieces = outline(&loop_of(&left, w, h), w, h, &|_| (0.0, 1, [0.1, 0.3, 4.0, 4.0, 4.0]), &grid); + let pts = &inner(&pieces).pts; + assert!([(15.0, 10.0), (15.0, 20.0), (25.0, 20.0), (25.0, 30.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + } + + #[test] + fn a_staircase_that_runs_on_from_a_smooth_area_into_an_edge_keeps_the_edges_corners() { + // One staircase of steps two pixels across and three down, from the top of the image to its + // bottom: its cracks above row 18 read as the contour of a smooth area, the others as an + // edge. Read as a whole, the run would pass as a contour. + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let step = |c: Crack| if c.at(0).unwrap().min(c.at(1).unwrap()) / w < 18 { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] }; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + assert!([(3.0, 3.0), (4.0, 4.5), (9.0, 12.0), (11.0, 15.0), (12.0, 16.5)].map(Point::from).iter().all(|m| pts.contains(m)), "{pts:?}"); + assert!([(4.0, 3.0), (10.0, 15.0)].map(Point::from).iter().all(|c| !pts.contains(c)), "{pts:?}"); + // The corner where the staircase meets the edge's first step stays. + assert!([(14.0, 18.0), (14.0, 21.0), (16.0, 21.0), (16.0, 24.0), (18.0, 24.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + } + + #[test] + fn a_staircase_whose_cracks_do_not_read_as_a_contour_keeps_its_corners() { + // Each step's cracks show a tenth of the step near them, but nothing changes within either + // side: two flat colors, not a smooth area. + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let flat = [0.1, 0.3, 4.0, 4.0, 0.0]; + let a = outline(&loop_of(&left, w, h), w, h, &|_| (0.0, 1, flat), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|_| (0.0, 2, flat), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + assert!([(4.0, 3.0), (4.0, 6.0), (6.0, 6.0), (10.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!([(4.0, 4.5), (9.0, 12.0)].map(Point::from).iter().all(|m| !pts.contains(m)), "{pts:?}"); + } + + #[test] + fn a_staircase_step_moves_by_the_mean_shift_of_its_cracks() { + // The staircase of steps two pixels across and three down, in a smooth area. Down a step, + // its cracks move 0, 1/8 and 1/4 px; across one, 0 and 1/8 px (out of the left region; the + // right region sees them negated). + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let shift = |c: Crack| { + let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap()); + let v = if p / w == q / w { 0.125 * (p / w % 3) as f64 } else { 0.125 * (p % w % 2) as f64 }; + if left[p] { v } else { -v } + }; + let gentle = [0.1, 0.3, 4.0, 4.0, 4.0]; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (shift(c), 1, gentle), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (shift(c), 2, gentle), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + assert!(!ours.hard); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + // A step down at x = 4 moves right by 1/8 px; the step across at y = 6 moves up by 1/16 px. + assert!([(4.125, 4.5), (5.0, 5.9375), (6.125, 7.5), (7.0, 8.9375)].map(Point::from).iter().all(|m| pts.contains(m)), "{pts:?}"); + } + + #[test] + fn a_staircase_of_mixed_one_pixel_and_longer_steps_is_one_slope_without_a_notch() { + // Per four rows: three down, one across, one down, three across. Each three-pixel step meets + // the next one with no one-pixel step between them, and the one-pixel steps are a notch + // between two longer ones. All of it is the contour of a smooth area, and the cracks move by + // different amounts. + let (w, h) = (26, 24); + let edge = |y: usize| 2 + 4 * (y / 4) + usize::from(y % 4 == 3); + let left: Vec = (0..w * h).map(|i| i % w < edge(i / w)).collect(); + let right: Vec = left.iter().map(|b| !b).collect(); + let shift = |c: Crack| { + let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap()); + let v = 0.125 * ((p + q) % 3) as f64 - 0.125; + if left[p] { v } else { -v } + }; + let gentle = [0.1, 0.3, 4.0, 4.0, 4.0]; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (shift(c), 1, gentle), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (shift(c), 2, gentle), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + let (mut ours, theirs) = (inner(&a), inner(&b)); + assert!(!ours.hard); + ours.pts.reverse(); + assert_eq!(ours, theirs); + let pts = &theirs.pts; + let near = |x: f64, y: f64, d: f64| pts.iter().any(|p| (p.x - x).hypot(p.y - y) < d); + for k in 1..5 { + let (c, y) = ((2 + 4 * k) as f64, (4 * k) as f64); + // The middle of each step, and no point at the corner where the three-pixel steps + // meet or at the corner the notch would make. + assert!(near(c, y + 1.5, 0.2) && near(c + 2.5, y + 4.0, 0.2), "{k}: {pts:?}"); + assert!(!near(c, y, 0.4) && !near(c, y + 4.0, 0.4), "{k}: {pts:?}"); + } + } + + /// Every point of every piece that `mask` and the region around it get in a smooth area, with + /// `junction_at` and the near share of each crack from `near`. + fn both_sides( + mask: &[bool], + w: usize, + h: usize, + near: &dyn Fn(Crack) -> [f64; 5], + junction_at: &dyn Fn(i32, i32) -> Option, + ) -> [Vec; 2] { + let other: Vec = mask.iter().map(|b| !b).collect(); + [(mask, 1), (&other[..], 2)] + .map(|(m, id)| outline(&loop_of(m, w, h), w, h, &|c| (0.0, id, near(c)), junction_at).into_iter().flat_map(|p| p.pts).collect()) + } + + #[test] + fn a_run_of_two_steps_keeps_its_corners() { + // Ten rows down, two across, three down, ten across: only two edges are short enough to + // be steps. + let (w, h) = (20, 20); + let edge = |y: usize| match y { + 0..10 => 2, + 10..13 => 4, + _ => 14, + }; + let left: Vec = (0..w * h).map(|i| i % w < edge(i / w)).collect(); + for pts in both_sides(&left, w, h, &|_| [0.1, 0.3, 4.0, 4.0, 4.0], &grid) { + assert!([(2.0, 10.0), (4.0, 10.0), (4.0, 13.0), (14.0, 13.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!(!pts.contains(&Point::new(4.0, 11.5)), "{pts:?}"); + } + } + + #[test] + fn a_junction_on_a_step_ends_the_staircase_there() { + // The staircase of steps two pixels across and three down, in a smooth area, with a + // junction at (8, 10), on the step down from (8, 9) to (8, 12). + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let junction_at = |x: i32, y: i32| (x == 8 && y == 10).then(|| Point::new(8.0, 10.0)); + for pts in both_sides(&left, w, h, &|_| [0.1, 0.3, 4.0, 4.0, 4.0], &junction_at) { + // The step keeps the corners at both its ends; the steps on either side of it are slopes. + assert!([(8.0, 9.0), (8.0, 10.0), (8.0, 12.0), (5.0, 6.0), (11.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!(!pts.contains(&Point::new(8.0, 10.5)) && !pts.contains(&Point::new(6.0, 6.0)), "{pts:?}"); + } + } + + #[test] + fn a_step_whose_cracks_read_as_an_edge_ends_the_staircase_there() { + // The same staircase in a smooth area, but the cracks of the step down from (8, 9) to + // (8, 12) cannot be read across. + let (w, h) = (20, 27); + let left: Vec = (0..w * h).map(|i| i % w < 2 + 2 * (i / w / 3)).collect(); + let near = |c: Crack| { + let (p, q) = (c.at(0).unwrap(), c.at(1).unwrap()); + if p / w == q / w && (9..12).contains(&(p / w)) && (p % w).max(q % w) == 8 { EDGE_STEP } else { [0.1, 0.3, 4.0, 4.0, 4.0] } + }; + for pts in both_sides(&left, w, h, &near, &grid) { + assert!([(8.0, 9.0), (8.0, 12.0), (5.0, 6.0), (11.0, 15.0)].map(Point::from).iter().all(|c| pts.contains(c)), "{pts:?}"); + assert!(!pts.contains(&Point::new(8.0, 10.5)) && !pts.contains(&Point::new(6.0, 6.0)), "{pts:?}"); + } + } + #[test] fn a_shift_that_is_not_a_number_moves_nothing() { let mask = [true, true, false, false, true, true, false, false]; diff --git a/crates/ui-egui/src/addons/select/link_status.rs b/crates/ui-egui/src/addons/select/link_status.rs index 0b367a7ba0..d50d650215 100644 --- a/crates/ui-egui/src/addons/select/link_status.rs +++ b/crates/ui-egui/src/addons/select/link_status.rs @@ -52,12 +52,19 @@ pub struct Present { pub has: [bool; 4], } -/// The statuses the `links.list` result `list` holds, in [`STATUSES`] order. +/// The statuses the rows of the `links.list` result `list` that can be selected hold, in +/// [`STATUSES`] order. pub(crate) fn statuses_in(list: &Value) -> [bool; 4] { - let rows = list["links"].as_array().map_or(&[][..], Vec::as_slice); + let rows = canvas_rows(list); STATUSES.map(|(status, ..)| rows.iter().any(|r| command_status(r) == status)) } +/// The rows of the `links.list` result `list` of objects in the layer tree, which can be +/// selected (a row with an `owner` is in a symbol, graph design, pattern or opacity mask). +fn canvas_rows(list: &Value) -> Vec { + list["links"].as_array().into_iter().flatten().filter(|r| r["owner"].is_null()).cloned().collect() +} + /// The status of a `links.list` row as the command names it (`ok` is `linked`). fn command_status(row: &Value) -> &str { match row["status"].as_str().unwrap_or_default() { @@ -154,11 +161,11 @@ pub(crate) fn message(statuses: &[&str], count: u64, reasons: &[&str]) -> String crate::i18n::msg_n(reasons.len() as u64, one, other, &[("selected", &selected)]) } -/// The dialog's fields to start with: every image and placed document (`__rows`, as `links.list` -/// lists them, read once), those selected chosen, All shown, Zoom to Selection on. +/// The dialog's fields to start with: every image and placed document in the layer tree (`__rows`, +/// as `links.list` lists them, read once), those selected chosen, All shown, Zoom to Selection on. pub(crate) fn fields(app: &mut VectorcraftApp) -> Value { let list = app.session.execute("links.list", &json!({})).unwrap_or_default(); - let rows: Vec = list["links"].as_array().cloned().unwrap_or_default(); + let rows = canvas_rows(&list); let selected: Vec = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default(); let chosen: Vec = rows.iter().filter_map(|r| r["id"].as_u64()).filter(|id| selected.contains(id)).collect(); json!({ "__rows": rows, "ids": chosen, "show": "all", "zoom": true }) diff --git a/crates/ui-egui/src/addons/select/link_status/tests.rs b/crates/ui-egui/src/addons/select/link_status/tests.rs index 8ad61f6e5a..c0515e048b 100644 --- a/crates/ui-egui/src/addons/select/link_status/tests.rs +++ b/crates/ui-egui/src/addons/select/link_status/tests.rs @@ -138,6 +138,27 @@ fn menu_items_are_on_for_the_statuses_present_and_shift_adds() { assert!(on("Select Embedded")); } +/// A missing link kept only in a symbol's art can't be selected: Select Missing stays off and +/// the dialog does not list it. +#[test] +fn links_inside_a_symbol_are_left_out_of_selecting_by_status() { + let dir = Folder::new("symbol"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let path = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": path, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + std::fs::remove_file(&path).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + assert_eq!(list["missing"], 1, "{list}"); + refresh(&mut app, 0.0); + let entries = menus::menu_entries(&app); + let missing = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Selection Tools"] && e.label == "Select Missing").unwrap(); + assert!(!missing.enabled); + assert_eq!(super::fields(&mut app)["__rows"], json!([])); +} + #[test] fn the_links_panel_menu_lists_the_select_items() { let dir = Folder::new("flyout"); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c479e8c63e..678616186b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -345,6 +345,8 @@ impl VectorcraftApp { screen_color::poll(self, ctx); self.poll_finished_work(); crate::script_runs::poll(self); + // A panel's, a dialog's or the text input's commit journaled in the frame before. + crate::panels::actions::after_journaled_edit(self); crate::scripts_menu::poll(self, ctx); if self.session.script_running() && !self.session.script_waiting() && self.session.script_dialog().is_none() { // A script in `$.sleep` goes on when its time is up; until then the app can idle. diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 1d2493385e..50c7b1be30 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -224,13 +224,10 @@ impl VectorcraftApp { } // While an action records, a command that the recording would keep as a step runs only // when the step fits in the action. - let recorded = match panels::actions::before_recorded_command(self, id, ¶ms) { - Ok(recorded) => recorded, - Err(e) => { - self.ui.status = e.clone(); - return Err(e); - } - }; + if let Err(e) = panels::actions::before_recorded_command(self, id, ¶ms) { + self.ui.status = e.clone(); + return Err(e); + } // A command that changes the edited text is an application edit (undo, a panel or menu // edit, a control request). Capture the bounded committed context around it so `Other` // dominates the frame even if an IME receipt follows. Selection/view commands that leave @@ -240,7 +237,7 @@ impl VectorcraftApp { let ime_before = crate::ime::stamp(&mut self.session, ime_view); let cursor_settings = (self.session.caps_lock, self.session.prefs.use_precise_cursors); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); - let r = panels::actions::after_recorded_command(self, recorded, r); + let r = panels::actions::after_recorded_command(self, r); if r.is_ok() && cursor_settings != (self.session.caps_lock, self.session.prefs.use_precise_cursors) { self.canvas.hover_cursor = None; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96e08db309..d6ac0afd12 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -67,8 +67,10 @@ pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; pub(crate) use raster::{color_image, color_image_owned, render_job}; pub use selection_marks::SelectionMarks; +#[cfg(test)] +pub(crate) use task_bar::chip_paints; use task_bar::task_bar; -pub(crate) use task_bar::{has_colors, task_bar_rect}; +pub(crate) use task_bar::{has_colors, task_bar_rect, task_bar_shows_fonts, task_bar_shows_type}; #[cfg(test)] use tool_overlays::{DIAL_RADIUS, HiddenMarks}; pub use tool_overlays::{HiddenChars, SpellingMarks}; diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 8b368fa496..ca017bef3e 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -83,7 +83,10 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { | Cursor::CornerRadius | Cursor::PathBracket | Cursor::TypeWidget - | Cursor::LoadedText => C::Default, + | Cursor::LoadedText + | Cursor::LivePaintSelectFace + | Cursor::LivePaintSelectEdge + | Cursor::LivePaintSelectOutside => C::Default, Cursor::SelectionCopy => C::Copy, Cursor::Move => C::Move, Cursor::Crosshair => C::Crosshair, diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 1a385d5642..414746310f 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -399,7 +399,9 @@ fn pressed_with(app: &mut VectorcraftApp, index: Option, pos: Point, tool } } -/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. +/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error. When an +/// edit the tool committed or ran no longer fits in the action being recorded, the edit stays and +/// the status bar names the limit ([`crate::panels::actions::after_journaled_edit`]). pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result>) { match r { Ok(reqs) => { @@ -420,6 +422,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result app.status(e.to_string()), } diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 7eb31f7d01..aefd9253e9 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -252,7 +252,9 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: /// The Appearance and Easing widgets of one selected blend (Selection and Blend tools), each joined /// by a line to the spine's halfway point: the Appearance widget hollow above it, the Easing widget -/// filled below it. Drawn with the edges hidden too, as they are controls. +/// filled below it. For a blend without a spine the line goes to the halfway point of the straight +/// lines between its key centers, which are not drawn. Drawn with the edges hidden too, as they are +/// controls. pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { use vectorcraft_tools::blendwidget::{BlendControl, BlendWidget, RADIUS_PX}; if !matches!(app.session.tool_id(), "selection" | "blend") { diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 5372cc083d..e908640c25 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -13,6 +13,12 @@ pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option { ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) } +/// Whether the Contextual Task Bar, where it was last drawn, held the font controls: on a canvas +/// too narrow for them it leaves them out. +pub(crate) fn task_bar_shows_fonts(ctx: &egui::Context) -> bool { + task_bar_rect(ctx).is_some() && ctx.data(|d| d.get_temp::(task_bar_id().with("fonts")).unwrap_or(false)) +} + /// Whether the objects `ids` (or anything inside them) are painted with a colour: a solid colour, /// a gradient's stops or a mesh's points, which Recolor Artwork can change. pub(crate) fn has_colors(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> bool { @@ -30,21 +36,59 @@ fn picked_artboards_bounds(app: &VectorcraftApp) -> Option { picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b)) } +/// The Type tools whose Contextual Task Bar shows the text formatting controls. The Touch Type +/// tool has no bar: its own handles take the place of one. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// Whether every selected object is a type object, with no anchors picked out. +fn only_type_selected(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| { + !st.selection.is_empty() + && st.selection.anchors.is_empty() + && st.selection.objects.iter().all(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) + }) +} + +/// Whether a selection tool is in use with something selected. +fn selection_tool_with_selection(app: &VectorcraftApp) -> bool { + matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") + && app.session.active().is_some_and(|st| !st.selection.is_empty()) +} + +/// Whether a Type tool edits type, or has type selected: its bar then shows only the text +/// formatting controls. +fn type_tool_on_type(app: &VectorcraftApp) -> bool { + TYPE_TOOLS.contains(&app.session.tool_id()) && (crate::panels::character::text_editing(app).is_some() || only_type_selected(app)) +} + +/// Whether the Contextual Task Bar shows the font family, font style and Font Size controls: a +/// Type tool edits or has selected type, or a selection tool has only type selected. +pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { + type_tool_on_type(app) || (selection_tool_with_selection(app) && only_type_selected(app)) +} + /// The Contextual Task Bar: a floating pill under the selection with the most likely next actions. /// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard /// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens). /// A basic shape selected with the Selection tool shows a Shape Properties button in place of Lock. +/// Selected type also shows the font family, font style and Font Size controls before its Fill. +/// While a Type tool edits type, or has type selected, the bar shows only those controls and the +/// Fill. The font controls take a second row when one row would be wider than the canvas, and +/// narrow there to fit it. /// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the /// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let t = Tokens::get(ui.ctx()); let on_artboards = app.session.tool_id() == "artboard"; - if !on_artboards - && (app.session.active().is_none_or(|st| st.selection.is_empty()) - || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")) - { + // Text added with a Type tool shows its formatting controls, as the help page describes + // (). + let typing = type_tool_on_type(app); + if !(on_artboards || typing || selection_tool_with_selection(app)) { return; } + // Selected type has the font controls too, as the help page describes + // (). + let fonts = task_bar_shows_type(app); let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; let Some(st) = app.session.active() else { return }; let n = st.selection.len(); @@ -93,7 +137,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); } items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); - } else { + } else if !typing { + // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines + // and the type conversions included. // Edit Path: the Direct Selection tool, to edit the path's anchors. let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and @@ -185,22 +231,31 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } - // The paints the Fill and Stroke proxies show, "?" when the selected objects' paints differ. - let (fill, stroke) = crate::panels::current_paints(app); - let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ui.ctx()); + let (fill, stroke, fill_mixed, stroke_mixed) = chip_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; // Under the selection's lowest corner on screen, centered on the selection (the view may be // rotated). let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); let anchor = pos2(xf.to_screen(b.center()).x, lowest); - let est_w = 150.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let bounds = xf.rect.shrink(8.0); + // On a canvas too narrow for the type controls at their narrowest, the bar leaves them out. + let fonts = fonts && bounds.width() - 18.0 >= crate::panels::character::task_bar_min_room(); + ui.ctx().data_mut(|d| d.insert_temp(task_bar_id().with("fonts"), fonts)); + // The width of the bar's frame (margins and stroke), its handle, Fill and Stroke chips, + // Recolor Artwork, Lock or Shape Properties, and More Options, each after a 6-point gap. + let chips: f32 = + [(!on_artboards, 26.0), (!on_artboards && !typing, 26.0), (!on_artboards && !typing && colored, 30.0), (!on_artboards && !typing, 30.0)] + .iter() + .filter(|(shown, _)| *shown) + .map(|(_, w)| w + 6.0) + .sum(); + let (font_row, est) = size_estimate(&items, fonts, 18.0 + 4.0 + chips + 36.0, bounds.width()); // Its place under the selection, then where the handle moved it, kept on the canvas (with the // bar's size last frame, the estimate before it first shows). - let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); + let under = pos2(anchor.x - est.x / 2.0, anchor.y + 28.0); let id = task_bar_id(); - let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0)); - let bounds = xf.rect.shrink(8.0); + let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(est); let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min)); let place = &mut app.ui.task_bar_place; // Just unpinned: follow the selection from the pinned spot. @@ -255,23 +310,21 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { run = Some((*cmd, params.clone())); } } + // Type's font family, font style and Font Size, which act as the Character + // panel's do: on the characters a Type tool selects, else on the selected type. + if fonts && !font_row { + crate::panels::character::task_bar(app, ui, f32::INFINITY); + } // The selected objects' Fill, Stroke, Recolor Artwork and Lock (a basic shape's - // properties instead), left out with the Artboard tool. - if !on_artboards { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &fill, fill_mixed, None); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Fill")).clicked() { - app.session.fill_active = true; - show_swatches = true; - } + // properties instead), left out with the Artboard tool. A Type tool's bar has + // the Fill alone. + if !on_artboards && paint_chip(ui, &t, &fill, fill_mixed, None, tl!("Fill")) { + app.session.fill_active = true; + show_swatches = true; + } + if !on_artboards && !typing { // The Stroke chip, drawn as a ring as the Control bar's is. - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &stroke, stroke_mixed, Some(4.0)); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Stroke")).clicked() { + if paint_chip(ui, &t, &stroke, stroke_mixed, Some(4.0), tl!("Stroke")) { app.session.fill_active = false; show_swatches = true; } @@ -316,6 +369,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } }); }); + if font_row { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + // The canvas's width less the frame's margins and stroke. + crate::panels::character::task_bar(app, ui, bounds.width() - 18.0); + }); + } }); }); if moved != Vec2::ZERO { @@ -353,6 +413,42 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } +/// Whether the type controls go on a second row, and the bar's estimated size before it first +/// shows: `items` and, when `fonts`, the font family, font style and Font Size controls, which take +/// a second row when one row, with the bar's other controls `fixed` points wide, would be wider than +/// `room` (and narrow to fit it there). +fn size_estimate(items: &[(&str, &str, &str, serde_json::Value)], fonts: bool, fixed: f32, room: f32) -> (bool, Vec2) { + let font_w: f32 = crate::panels::character::TASK_BAR_WIDTHS.iter().filter(|_| fonts).map(|w| w + 6.0).sum(); + let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + if fonts && items_w + font_w > room { (true, vec2(items_w.max((font_w + 16.0).min(room)), 80.0)) } else { (false, vec2(items_w + font_w, 44.0)) } +} + +/// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or +/// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints. +pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) { + let (fill, stroke) = crate::panels::current_paints(app); + let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ctx); + if crate::panels::character::text_editing(app).is_some() + && let Some((chars, _)) = crate::panels::character::text_style(app) + { + let mixed = crate::panels::character::shared(app, |s| s.fill.clone()).is_none(); + return (chars.fill, stroke, mixed, stroke_mixed); + } + (fill, stroke, fill_mixed, stroke_mixed) +} + +/// One of the Contextual Task Bar's Fill and Stroke chips, showing `paint` ("?" when `mixed`, a +/// ring of `ring` points for the stroke), named `tip` for screen readers. Returns whether it was +/// clicked. +fn paint_chip(ui: &mut Ui, t: &Tokens, paint: &vectorcraft_color::Paint, mixed: bool, ring: Option, tip: &str) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, paint, mixed, ring); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + resp.on_hover_text(tip).clicked() +} + /// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a /// closed path. fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 7cb577c812..5ac4025c24 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -78,7 +78,8 @@ pub(super) const ISOLATION_BUTTON: &str = "cb-isolation"; /// less, the menu is left out so those controls still fit the narrowest window. const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0; -/// The Control bar's snapping quick access; every change uses its existing command. +/// The Control bar's snapping quick access; every change uses its existing command, and Pixel +/// Snapping Options… opens the dialog of `view.snapToPixel.options`. fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.menu_button(tl!("Snapping"), |ui| { for (label, cmd, on) in [ @@ -92,6 +93,10 @@ fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { crate::menus::invoke(app, cmd, json!({})); } } + if ui.button(tl!("Pixel Snapping Options…")).clicked() { + ui.close(); + crate::menus::invoke(app, "view.snapToPixel.options", json!({})); + } ui.separator(); for (key, label, on) in [ ("alignmentGuides", tl!("Alignment Guides"), app.session.prefs.alignment_guides), diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 346dcb9f05..376450937c 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2247,6 +2247,82 @@ fn the_control_bar_snapping_menu_shares_glyph_and_alignment_settings() { check(&nodes, "Alignment Guides", true); } +/// The Control bar's Snapping menu opens Pixel Snapping Options with the current options; OK sets +/// the three choices the dialog shows, and a dialog closed without OK changes nothing. +#[test] +fn the_control_bar_snapping_menu_opens_pixel_snapping_options() { + struct RestoreLanguage(crate::i18n::Lang); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_current(self.0); + } + } + let _language = RestoreLanguage(crate::i18n::current()); + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("view.snapToPixel.options", json!({"moving": false})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 400.0)); + let frame = |app: &mut crate::VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default() + }; + let center = |nodes: &[(egui::accesskit::NodeId, egui::accesskit::Node)], name: &str| { + let n = nodes.iter().find(|(_, n)| n.label() == Some(name)).map(|(_, n)| n.clone()).unwrap_or_else(|| panic!("missing {name}")); + let b = n.bounds().expect("a visible menu control"); + egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) + }; + let click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| { + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, vec![egui::Event::PointerMoved(at), press(true)]); + frame(app, vec![press(false)]); + frame(app, vec![]); + frame(app, vec![]) + }; + frame(&mut app, vec![]); + let nodes = frame(&mut app, vec![]); + let menu = center(&nodes, "Snapping"); + let nodes = click(&mut app, menu); + let entry = center(&nodes, "Pixel Snapping Options…"); + click(&mut app, entry); + let d = app.ui.dialog.as_ref().expect("Pixel Snapping Options"); + assert_eq!(d.kind, crate::dialogs::pixel_snap_options::KIND); + assert_eq!(serde_json::Value::Object(d.fields.clone()), json!({"drawing": true, "moving": false, "scaling": true})); + // While View › Snap to Pixel is off, the dialog says the options wait for it. + // A new window is laid out unseen in its first frame, so the second frame is read. + let note = |app: &mut crate::VectorcraftApp| { + let mut nodes = vec![]; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default(); + } + let shown = |text: &str| nodes.iter().any(|(_, n)| n.label().or(n.value()).is_some_and(|l| l.contains(text))); + (shown("Snap to Pixel While Drawing"), shown("take effect when View › Snap to Pixel is on")) + }; + assert!(!app.ui.view.snap_to_pixel); + assert_eq!(note(&mut app), (true, true), "the note shows with Snap to Pixel off"); + app.ui.view.snap_to_pixel = true; + assert_eq!(note(&mut app), (true, false), "the note is hidden with Snap to Pixel on"); + let before = app.session.prefs.pixel_snap; + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("drawing".into(), json!(false)); + app.ui.dialog = None; + assert_eq!(app.session.prefs.pixel_snap, before, "a dialog closed without OK changes nothing"); + crate::menus::invoke(&mut app, "view.snapToPixel.options", json!({})); + let d = app.ui.dialog.as_mut().expect("Pixel Snapping Options"); + d.fields.insert("drawing".into(), json!(false)); + d.fields.insert("moving".into(), json!(true)); + d.fields.insert("scaling".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.prefs.pixel_snap, vectorcraft_tools::PixelSnap { drawing: false, moving: true, scaling: false }); +} + /// The window the Control bar docking tests draw the bar in. const DOCK_SCREEN: egui::Rect = egui::Rect { min: egui::Pos2::ZERO, max: egui::pos2(1200.0, 300.0) }; diff --git a/crates/ui-egui/src/clipboard_probe.rs b/crates/ui-egui/src/clipboard_probe.rs index d492893ce0..5028125e03 100644 --- a/crates/ui-egui/src/clipboard_probe.rs +++ b/crates/ui-egui/src/clipboard_probe.rs @@ -9,17 +9,11 @@ use std::time::{Duration, Instant}; use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; -use crate::sysclip::ClipboardProbeFactory; +use crate::sysclip::{CLIPBOARD_PROBE_STOP_WAIT as STOP_WAIT, ClipboardProbeFactory}; /// How often the worker looks at the clipboard while the UI wants to know. const PROBE_INTERVAL: Duration = Duration::from_millis(250); -/// How long dropping the probe (when the app quits) waits for the worker to let go of its clipboard -/// handle (VectorCraft's own bound). arboard's X11 reads wait up to 4 s for each text format they -/// ask for; when a look is still waiting after this bound, the app quits without waiting longer, -/// and what it copied may not reach the clipboard manager. -const STOP_WAIT: Duration = Duration::from_secs(5); - /// The background clipboard check ([`crate::VectorcraftApp`]'s `clipboard_probe`). pub(crate) struct Probe { /// Tells the worker whether Paste depends on the system clipboard; dropped to stop it. @@ -28,8 +22,14 @@ pub(crate) struct Probe { verdicts: Receiver, /// The worker's last verdict. last: bool, + /// How `Drop` waits for the worker: [`wait_for_worker`], or a controllable clock in tests. + pub(crate) stop: StopWait, } +/// Waits on the worker's verdicts, from the quit to the deadline [`stop_by`] gives, until the worker +/// is gone or the deadline passes. +pub(crate) type StopWait = Box, Instant, Instant) + Send>; + impl Probe { /// Start the worker. `None` on wasm, or when the thread can't be spawned: the caller then falls /// back to the in-line check. @@ -65,13 +65,14 @@ impl Probe { Err(RecvTimeoutError::Disconnected) => break, } } - // The handle goes before the app stops waiting (`Drop`): on X11 the last one alive - // hands what the app copied to the clipboard manager, which must finish before exit. + // The handle goes before `Drop` stops waiting, unless dropping it takes longer than + // `STOP_WAIT`: on X11 the last one alive hands what the app copied to the clipboard + // manager, which must finish before exit. drop(clipboard); drop(tx); }) .ok()?; - Some(Self { wants: Some(wants), wanted: false, verdicts, last: false }) + Some(Self { wants: Some(wants), wanted: false, verdicts, last: false, stop: Box::new(wait_for_worker) }) } /// Paste can take from the system clipboard: `wanted` (nothing copied in the app, a document @@ -96,7 +97,17 @@ impl Drop for Probe { /// never answers) until it has let go of its clipboard handle. fn drop(&mut self) { self.wants = None; - let end = Instant::now() + STOP_WAIT; - while self.verdicts.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {} + let quit = Instant::now(); + (self.stop)(&self.verdicts, quit, stop_by(quit)); } } + +/// When dropping the probe at `quit` stops waiting for its worker. +pub(crate) fn stop_by(quit: Instant) -> Instant { + quit + STOP_WAIT +} + +/// Waits until the worker has dropped its sender (it has let go of its clipboard handle) or `end`. +fn wait_for_worker(verdicts: &Receiver, _quit: Instant, end: Instant) { + while verdicts.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {} +} diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 34156f2c41..a438a71881 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -468,6 +468,27 @@ fn paint_brush(p: &mut Ink, o: Pos2) { } } +/// Live Paint Selection: the solid arrow with a badge at its lower right saying what a click +/// selects: a filled square over a face ([`Cursor::LivePaintSelectFace`]), a short line between two +/// anchor marks over an edge ([`Cursor::LivePaintSelectEdge`]), an x where it selects nothing. +fn live_paint_select(p: &mut Ink, o: Pos2, part: Cursor) { + arrow(p, o, false); + let b = o + vec2(12.0, 13.0); + match part { + Cursor::LivePaintSelectFace => { + poly(p, [(0.0, 0.0), (6.0, 0.0), (6.0, 6.0), (0.0, 6.0)].map(|(x, y)| b + vec2(x, y)).to_vec(), INK, INK); + } + Cursor::LivePaintSelectEdge => { + let (a, c) = (b + vec2(0.0, 7.0), b + vec2(7.0, 0.0)); + strokes(p, &[vec![a, c]]); + for m in [a, c] { + poly(p, [(-1.5, -1.5), (1.5, -1.5), (1.5, 1.5), (-1.5, 1.5)].map(|(x, y)| m + vec2(x, y)).to_vec(), INK, INK); + } + } + _ => strokes(p, &[vec![b, b + vec2(6.0, 6.0)], vec![b + vec2(6.0, 0.0), b + vec2(0.0, 6.0)]]), + } +} + /// The Hand tool and Space: an open hand (palm and four fingers, thumb to the left), or the same /// hand closed while it drags the view. Windows has no system hand that isn't a pointing finger /// (winit maps egui's Grab and Grabbing to the four-headed move cursor), so it is drawn like the @@ -516,7 +537,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { /// Every cursor with a glyph: its own, drawn here, or the one it borrows. #[cfg(test)] -const GLYPHS: [Cursor; 95] = [ +const GLYPHS: [Cursor; 98] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -586,6 +607,9 @@ const GLYPHS: [Cursor; 95] = [ Cursor::LivePaintBucket, Cursor::LivePaintBucketFace, Cursor::LivePaintBucketEdge, + Cursor::LivePaintSelectFace, + Cursor::LivePaintSelectEdge, + Cursor::LivePaintSelectOutside, Cursor::Measure, Cursor::PrintTiling, Cursor::SymbolSprayer, @@ -710,6 +734,7 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::LivePaintBucket => paint_bucket(ink, p, false), Cursor::LivePaintBucketFace => paint_bucket(ink, p, true), Cursor::LivePaintBucketEdge => paint_brush(ink, p), + Cursor::LivePaintSelectFace | Cursor::LivePaintSelectEdge | Cursor::LivePaintSelectOutside => live_paint_select(ink, p, c), Cursor::Hand => hand(ink, p, false), Cursor::HandGrab => hand(ink, p, true), Cursor::ZoomIn => magnifier(ink, p, false), diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index 4a12ff91b6..ae1947616f 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -331,6 +331,45 @@ fn the_live_paint_bucket_shows_a_bucket_or_a_brush() { } } +/// Live Paint Selection's cursors are the solid arrow, its tip on the hotspot, with a badge at its +/// lower right: a filled square over a face, a line between two anchor marks over an edge, and an x +/// where a click selects nothing. +#[test] +fn live_paint_selection_shows_the_arrow_with_a_face_edge_or_x_badge() { + for ppp in [2.0f32, 3.0] { + let img = |c| rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}")); + let (arrow, cross) = (img(Cursor::Arrow), img(Cursor::Crosshair)); + let (face, edge, out) = (img(Cursor::LivePaintSelectFace), img(Cursor::LivePaintSelectEdge), img(Cursor::LivePaintSelectOutside)); + // The pixel at (dx, dy) points from the hotspot. + let at = |img: &CustomCursorImage, dx: f32, dy: f32| { + let [hx, hy] = img.hotspot.map(usize::from); + let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); + if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } + }; + let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; + // Inside the square, away from the x's strokes and the edge's line. + let square = (15.0, 14.0); + // The edge's upper anchor mark, beyond the square and the x. + let mark = (19.0, 13.0); + // On the x's stroke from top left to bottom right, away from the edge's line. + let x_stroke = (13.0, 14.0); + for (c, i, want) in [ + (Cursor::LivePaintSelectFace, &face, [true, false, true]), + (Cursor::LivePaintSelectEdge, &edge, [false, true, false]), + (Cursor::LivePaintSelectOutside, &out, [false, false, true]), + ] { + assert_eq!(i.hotspot, arrow.hotspot, "{c:?} has the arrow's hotspot at {ppp}"); + assert!(inked(at(i, 1.5, 6.0)), "{c:?} is the solid arrow at {ppp}"); + let got = [square, mark, x_stroke].map(|(x, y)| inked(at(i, x, y))); + assert_eq!(got, want, "{c:?} at {ppp}"); + assert!(c.stand_in().is_none(), "{c:?} is drawn"); + assert_ne!(i, &arrow, "{c:?} is not the plain arrow at {ppp}"); + assert_eq!(rasterize(c.precise(), ppp).as_ref(), Some(&cross), "{c:?} with Use Precise Cursors at {ppp}"); + } + assert!(face != edge && edge != out && face != out, "three looks at {ppp}"); + } +} + #[test] fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() { let mut images = Images::default(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 197f8faaaa..6040ac8fb4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -88,6 +88,7 @@ pub mod perspective_grid; pub mod perspective_options; pub mod perspective_plane; pub mod perspective_presets; +pub mod pixel_snap_options; pub mod place; pub mod placement_options; pub mod plugin; @@ -452,6 +453,7 @@ registry! { LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC, NavigatorPanelOptions: [navigator_panel_options::KIND] => navigator_panel_options::SPEC, PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC, + PixelSnapOptions: [pixel_snap_options::KIND] => pixel_snap_options::SPEC, RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC, RenameArtboards: [rename_artboards::KIND] => rename_artboards::SPEC, PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC, diff --git a/crates/ui-egui/src/dialogs/color_picker/tests.rs b/crates/ui-egui/src/dialogs/color_picker/tests.rs index 4d678b1069..5587b9c560 100644 --- a/crates/ui-egui/src/dialogs/color_picker/tests.rs +++ b/crates/ui-egui/src/dialogs/color_picker/tests.rs @@ -106,6 +106,94 @@ fn gamut_correction_gives_a_printable_colour() { assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None); } +/// The cached warning follows the CMYK space it is checked in: assigning the document a CMYK +/// profile, removing it, or changing the working CMYK space updates it for the same color. +#[test] +fn gamut_warning_follows_a_profile_change() { + const CHILD: &str = "VECTORCRAFT_TEST_GAMUT_PROFILE_CHANGE"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: change them in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "dialogs::color_picker::tests::gamut_warning_follows_a_profile_change", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated gamut test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms; + let mut app = app(); + let ctx = egui::Context::default(); + let blue = Color::rgb(0.0, 0.0, 1.0); + let fix = gamut_fix(&mut app, &ctx, "picker-gamut", &blue); + assert!(fix.is_some(), "pure blue is out of the default CMYK gamut"); + // Device CMYK has no gamut to fall outside of. + app.run("edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK})).unwrap(); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), None); + app.run("edit.assignProfile", json!({"cmyk": null})).unwrap(); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), fix); + app.run("edit.colorSettings", json!({"cmyk": cms::DEVICE_CMYK})).unwrap(); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), None); + app.run("edit.colorSettings", json!({"cmyk": cms::GENERIC_CMYK})).unwrap(); + assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), fix); +} + +/// Loading a new file of the document's assigned CMYK profile, under the same name, updates the +/// cached warning for the same color. +#[test] +fn gamut_warning_follows_a_profile_loaded_again() { + const CHILD: &str = "VECTORCRAFT_TEST_GAMUT_PROFILE_RELOAD"; + if std::env::var_os(CHILD).is_none() { + // Color settings and loaded profiles are process-wide: change them in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "dialogs::color_picker::tests::gamut_warning_follows_a_profile_loaded_again", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated gamut test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms; + // A built-in CMYK profile's file renamed `name`: padded with spaces, which names drop. + let renamed = |builtin: &str, name: &str| { + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(&format!("{name: { - let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards }); - if use_artboards { - params["range"] = json!(range); + let mut params = json!({ "format": f.id, "path": path }); + // A format that holds the whole document (a template) has no Use Artboards: the + // export is not given the option, which its warnings would name as not used. + if plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards") { + params["useArtboards"] = json!(use_artboards); + if use_artboards { + params["range"] = json!(range); + } } if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) { plugin::open_export(app, &plugin, params); diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index 05f0e85b60..2e99f5e97e 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -112,11 +112,12 @@ pub(crate) fn find_in_folder_command(app: &mut VectorcraftApp, folder: Option bool { let Some(id) = id.as_u64() else { return false }; let st = app.session.active(); - st.and_then(|st| st.doc.node(vectorcraft_doc::NodeId(id))).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) + st.and_then(|st| st.doc.stored_node(vectorcraft_doc::NodeId(id))).is_some_and(|(n, _)| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) } /// Make the dialog's document the active one: it may be another tab's. diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 4722b8b327..0b618c7e6f 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -255,6 +255,35 @@ fn copy_into_the_documents_folder_copies_before_relinking() { assert!(!frame(&mut app).contains("Copy into the document's folder")); } +/// Copy into the document's folder copies the file of an image in a symbol's art too, picked in the +/// Links panel by its id. +#[test] +fn copy_takes_an_image_in_a_symbol_into_the_documents_folder() { + let root = folder("copy-symbol"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let orig = at(&root, "orig/a.png"); + write(&orig, &png(3)); + let placed = app.run("file.place", json!({"path": orig})).unwrap()["ids"][0].clone(); + app.run("symbol.new", json!({"name": "Mark", "ids": [placed]})).unwrap(); + let doc = at(&root, "doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + write(&at(&root, "found/a.png"), &png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image"); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found"), "ids": [row["id"]]})).unwrap(); + searched(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().map(Vec::len), &r["failed"]), (Some(1), &json!([])), "{r}"); + let copy = at(&root, "doc/a.png"); + assert_eq!(std::fs::read(©).unwrap(), png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().unwrap(); + assert_eq!(row["path"], json!(copy), "{list}"); +} + /// With Copy into the document's folder, a placed document is relinked where its file is. #[test] fn copy_leaves_placed_documents_where_they_are() { diff --git a/crates/ui-egui/src/dialogs/pixel_snap_options.rs b/crates/ui-egui/src/dialogs/pixel_snap_options.rs new file mode 100644 index 0000000000..43b369e105 --- /dev/null +++ b/crates/ui-egui/src/dialogs/pixel_snap_options.rs @@ -0,0 +1,46 @@ +//! Pixel Snapping Options (the Control bar's Snapping menu): whether View › Snap to Pixel lands +//! drawing, moving and scaling on whole pixels. OK runs `view.snapToPixel.options` (the options live +//! in the preferences); Cancel leaves them as they were. +//! +//! Fields: `drawing`, `moving` and `scaling` (bool). While Snap to Pixel is off, the options stay +//! editable and a note says they take effect once it is on. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Pixel Snapping Options. +pub const KIND: &str = "pixelSnapOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Pixel Snapping Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open the dialog with the current options. +pub fn open(app: &mut VectorcraftApp) { + app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.pixel_snap))); +} + +fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) { + let v = d.bool(key); + if widgets::check(ui, text, v, true) { + d.fields.insert(key.into(), json!(!v)); + } +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + check(ui, d, "drawing", tl!("Snap to Pixel While Drawing")); + check(ui, d, "moving", tl!("Snap to Pixel While Moving")); + check(ui, d, "scaling", tl!("Snap to Pixel While Scaling")); + if !app.ui.view.snap_to_pixel { + let dim = Tokens::get(ui.ctx()).text_dim; + ui.label(egui::RichText::new(tl!("These options take effect when View › Snap to Pixel is on.")).color(dim)); + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + super::run_and_close(app, "view.snapToPixel.options", Value::Object(d.fields.clone())) +} diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index a3a6cc4768..fda9e78cc5 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -251,14 +251,14 @@ fn long_page_notes_are_translated() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 56_692.91, "height": 2_834.65})).unwrap(); let mut notes: Vec = vec![]; - for p in [json!({"compatibility": "1.5"}), json!({"preset": "PDF/X-4:2010"})] { + for p in [json!({"compatibility": "1.5", "longPagesInPoints": false}), json!({"preset": "PDF/X-4:2010"})] { let r = s.execute("document.exportPdf", &p).unwrap(); let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("14,400 pt")).map(str::to_string)); } notes.push(s.execute("document.exportPdf", &json!({"preset": "PDF/X-1a:2001"})).unwrap_err().to_string()); s.execute("file.new", &json!({"width": 85_040, "height": 17})).unwrap(); - let r = s.execute("document.exportPdf", &json!({})).unwrap(); + let r = s.execute("document.exportPdf", &json!({"longPagesInPoints": false})).unwrap(); let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("too long and narrow")).map(str::to_string)); assert_eq!(notes.len(), 5, "{notes:?}"); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 117e83277c..ca37940601 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -205,7 +205,8 @@ fn svg_continues_in_svg_options() { /// Without Use Artboards, a PDF continues in Save PDF too, without its artboard choice, and the /// file is one page of the art's bounds. Its options apply: a drawing 10 m long is written at -/// full size in points with "Write pages longer than 200 in at full size in points". +/// full size in points with "Write pages longer than 200 in at full size in points" (on by +/// default), and with a `UserUnit` without it. #[test] fn pdf_art_continues_in_the_save_pdf_dialog() { let (mut app, written) = app(2); @@ -223,13 +224,22 @@ fn pdf_art_continues_in_the_save_pdf_dialog() { app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap(); app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); confirm(&mut app).unwrap(); - set(&mut app, "longPagesInPoints", json!(true)); frame(&mut app); confirm(&mut app).unwrap(); let bytes = written.borrow()[1].1.clone(); assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width(); assert!((28_346.0..28_350.0).contains(&width), "{width}"); + + app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); + confirm(&mut app).unwrap(); + set(&mut app, "longPagesInPoints", json!(false)); + frame(&mut app); + confirm(&mut app).unwrap(); + let bytes = written.borrow()[2].1.clone(); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit")); + let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width(); + assert!((28_346.0..28_350.0).contains(&width), "{width}"); } #[test] diff --git a/crates/ui-egui/src/dialogs/three_d_materials.rs b/crates/ui-egui/src/dialogs/three_d_materials.rs index 180572aeaa..3091a00cac 100644 --- a/crates/ui-egui/src/dialogs/three_d_materials.rs +++ b/crates/ui-egui/src/dialogs/three_d_materials.rs @@ -1,13 +1,13 @@ //! The 3D and Materials panel's Materials tab: the document's Graphics (its symbols) as thumbnails, -//! Add as Single Graphic, and the graphics mapped onto the selected 3D object's planar faces with -//! their face, position, size and turn. Picking a thumbnail maps that graphic onto the selected -//! objects (`threeD.mapGraphic`); art dropped on the thumbnails becomes a graphic +//! Add as Single Graphic, and the graphics mapped onto the selected 3D object (its planar faces, or +//! Revolve's side) with their face, position, size and turn. Picking a thumbnail maps that graphic +//! onto the selected objects (`threeD.mapGraphic`); art dropped on the thumbnails becomes a graphic //! (`threeD.addGraphic`). Editing a mapped graphic changes the effect's `graphics` parameter, which //! the panel sends as it sends every other setting. use egui::{Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; -use vectorcraft_effects::{GRAPHICS, MappedGraphic, PlanarFace, ROTATE}; +use vectorcraft_effects::{GRAPHICS, MappedGraphic, PlanarFace, REVOLVE, ROTATE}; use super::revolve::{heading, label, number}; use crate::VectorcraftApp; @@ -96,7 +96,7 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) } ui.add_space(10.0); if !takes { - dim_label(ui, tl!("Graphics map onto planar faces: pick the Plane or Extrude object type.")); + dim_label(ui, tl!("Graphics map onto the Plane, Extrude and Revolve object types.")); return; } let raw = d.fields.get(GRAPHICS).cloned().unwrap_or(Value::Null); @@ -121,7 +121,8 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) ui.add_space(6.0); let Some(g) = list.get(index).cloned() else { return }; let mut edited = g.clone(); - if effect != ROTATE { + // A plane has one face, and Revolve's graphics go on its side. + if effect != ROTATE && effect != REVOLVE { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; label(ui, tl!("Face")); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 8e61f291e4..c7a74e3178 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -55,6 +55,8 @@ pub enum WheelOwner { Control, /// The Character popover attached to this Control bar link. ControlPopover(egui::Id), + /// The Contextual Task Bar's type controls. + TaskBar, } impl WheelOwner { @@ -68,6 +70,12 @@ impl WheelOwner { Self::ControlPopover(anchor) => { return app.ui.control_bar && app.ui.screen_mode < 2 && widgets::popover_is_open(ctx, anchor); } + Self::TaskBar => { + return app.ui.task_bar + && app.ui.screen_mode < 3 + && crate::canvas::task_bar_shows_type(app) + && crate::canvas::task_bar_shows_fonts(ctx); + } Self::Character => "character", Self::Properties => { let selected = app.session.active().and_then(|document| document.selection.objects.first().and_then(|id| document.doc.node(*id))); @@ -106,12 +114,24 @@ pub struct MenuLook<'a> { pub wheel: bool, /// Its owner, so a field hidden before the next layout cannot capture wheel input. pub wheel_owner: Option, + /// The name screen readers give the closed field when no label stands beside it. + pub name: Option<&'a str>, } impl Default for MenuLook<'static> { /// Samples on in the theme's colors, English names, medium rows, no favorites, no wheel. fn default() -> Self { - Self { samples: true, on_white: false, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None } + Self { + samples: true, + on_white: false, + english_names: true, + row: ROW, + favorites: &[], + recent: &[], + wheel: false, + wheel_owner: None, + name: None, + } } } @@ -131,6 +151,7 @@ impl<'a> MenuLook<'a> { recent: app.recent_fonts(), wheel: false, wheel_owner: None, + name: None, } } } @@ -367,7 +388,11 @@ pub fn font_menu( let state_id = ui.scope_id().with(&id).with("font-menu"); let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); ui.data(|d| d.get_temp::(filters_id())).unwrap_or_default().apply(&mut st); - let target = ui.data(|d| d.get_temp(wheel_fields_id(ui.ctx().viewport_id()).with("target"))); + // The viewport is read outside `ui.data`. The closure of `ui.data` runs with the context + // locked, and a second lock taken inside it blocks once a font-sample thread is waiting to + // request a repaint. + let wheel_fields = wheel_fields_id(ui.ctx().viewport_id()); + let target = ui.data(|d| d.get_temp(wheel_fields.with("target"))); let target_changed = st.wheel_target != target; if target_changed { st.wheel_order = None; @@ -378,7 +403,7 @@ pub fn font_menu( // The search field and filters stay in place over the rows, which scroll. The rows are all // one height (a family's styles too), so only those in view are laid out: there are thousands // of families. - let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| { + let (field, shown) = widgets::combo_menu_field_named(ui, &id, look.name, current, width, |menu| { menu.height(height).show_rows_with_header( |ui| { // Wider than a narrow field, so the filters keep their rows and long names show. @@ -435,10 +460,8 @@ pub fn font_menu( out = Some(FontPick::Wheel(steps)); } } - let queued = ui.data(|d| { - d.get_temp::(wheel_fields_id(ui.ctx().viewport_id())) - .is_some_and(|fields| fields.queued.iter().any(|event| event.field == field.id)) - }); + let queued = + ui.data(|d| d.get_temp::(wheel_fields).is_some_and(|fields| fields.queued.iter().any(|event| event.field == field.id))); if drawn || (!field.contains_pointer() && !queued) { st.wheel_order = None; ui.data_mut(|d| d.remove::(field.id.with("wheel"))); diff --git a/crates/ui-egui/src/free_transform.rs b/crates/ui-egui/src/free_transform.rs index dbebedffa2..9240c1e529 100644 --- a/crates/ui-egui/src/free_transform.rs +++ b/crates/ui-egui/src/free_transform.rs @@ -1,7 +1,7 @@ //! The Free Transform widget (#598): while the Free Transform tool is active, a small floating box //! at the canvas's top left with Constrain (as holding Shift: proportional scaling, moves and -//! rotations by 45°) over the tool's three modes, Free Transform, Perspective Distort and Free -//! Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents +//! rotations by 45°, and Free Distort corners kept on their diagonal) over the tool's three modes, +//! Free Transform, Perspective Distort and Free Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents //! do; the modifier keys keep working while dragging. use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; @@ -61,8 +61,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) { .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(60) }) .show(ui, |ui| { ui.spacing_mut().item_spacing.y = 2.0; - // Constrain shapes only what the Free Transform mode does. - if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode == "free") { + // Constrain shapes what the Free Transform and Free Distort modes do. + if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode != "perspective") { set = Some(("constrain", json!(!constrain))); } let (line, _) = ui.allocate_exact_size(vec2(BUTTON, 3.0), Sense::hover()); @@ -148,7 +148,8 @@ mod tests { } /// The widget shows with the Free Transform tool only; its buttons set the mode and Constrain - /// (inert in the distort modes); a corner dragged in Free Distort moves that corner alone. + /// (inert in Perspective Distort); a corner dragged in Free Distort moves that corner alone, and + /// with Constrain on it keeps to the diagonal through the opposite corner. #[test] fn the_widget_sets_the_mode_and_a_corner_drag_then_distorts() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -175,26 +176,50 @@ mod tests { assert_eq!(options(&app), ("free".into(), true), "Constrain on"); click(&mut app, &ctx, at(3)); assert_eq!(options(&app), ("distort".into(), true)); - click(&mut app, &ctx, at(0)); - assert_eq!(options(&app), ("distort".into(), true), "Constrain is inert in Free Distort"); click(&mut app, &ctx, at(2)); assert_eq!(options(&app).0, "perspective"); + click(&mut app, &ctx, at(0)); + assert_eq!(options(&app), ("perspective".into(), true), "Constrain is inert in Perspective Distort"); click(&mut app, &ctx, at(3)); - // Free Distort: the top-left corner dragged alone. + // Free Distort with Constrain on: the top-left corner dragged by (-20, -10) keeps to the + // diagonal (-100, -60), so it moves by 2,600 / 13,600 of it, about (-19.1, -11.5). let xf = Xf::new(canvas, app.view().unwrap()); - let (from, to) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(80.0, 90.0))); let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]); - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); - frame(&mut app, &ctx, vec![button(to, false)]); - frame(&mut app, &ctx, vec![]); - let st = app.session.active().unwrap(); - let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); - let pts: Vec = node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect(); - let near = |q: Point| pts.iter().any(|p| p.distance(q) < 0.5); + let drag = |app: &mut VectorcraftApp, from: Point, to: Point| { + let (from, to) = (xf.to_screen(from), xf.to_screen(to)); + frame(app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); + frame(app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]); + frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false)]); + frame(app, &ctx, vec![]); + }; + let corners = |app: &VectorcraftApp| -> Vec { + let st = app.session.active().unwrap(); + let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect() + }; + let has = |pts: &[Point], q: Point| pts.iter().any(|p| p.distance(q) < 0.5); + drag(&mut app, Point::new(100.0, 100.0), Point::new(80.0, 90.0)); + let k = (20.0 * 100.0 + 10.0 * 60.0) / (100.0f64 * 100.0 + 60.0 * 60.0); + let pts = corners(&app); assert!( - near(Point::new(80.0, 90.0)) && near(Point::new(200.0, 100.0)) && near(Point::new(200.0, 160.0)) && near(Point::new(100.0, 160.0)), + has(&pts, Point::new(100.0 - 100.0 * k, 100.0 - 60.0 * k)) + && has(&pts, Point::new(200.0, 100.0)) + && has(&pts, Point::new(200.0, 160.0)) + && has(&pts, Point::new(100.0, 160.0)), + "{pts:?}" + ); + app.run("edit.undo", json!({})).unwrap(); + // Constrain off in Free Distort: the corner moves with the pointer. + click(&mut app, &ctx, at(0)); + assert_eq!(options(&app), ("distort".into(), false), "Constrain works in Free Distort"); + drag(&mut app, Point::new(100.0, 100.0), Point::new(80.0, 90.0)); + let pts = corners(&app); + assert!( + has(&pts, Point::new(80.0, 90.0)) + && has(&pts, Point::new(200.0, 100.0)) + && has(&pts, Point::new(200.0, 160.0)) + && has(&pts, Point::new(100.0, 160.0)), "{pts:?}" ); assert_eq!(app.session.tool_id(), TOOL); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 08a8552d12..b19d6963c6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1537,6 +1537,8 @@ font-technology Variable Променливи Objects on Path Tool Инструмент „Обекти по пътя“ Attach Objects on Path Прикрепяне на обекти към пътя Expand Objects on Path Разширяване на обекти по пътя + Detach Откачване + Detach Object from Path Откачване на обект от пътя Release Live Paint Освобождаване на динамично оцветяване Release Repeat Освобождаване на повторение Release Slice Освобождаване на отрязък @@ -4519,6 +4521,7 @@ axis Both И двете @msg paths contain invalid coordinates пътищата съдържат невалидни координати @msg paths: give at least one file пътища: посочете поне един файл @msg replace: the object to replace was changed or deleted замяна: обектът за замяна е променен или изтрит +@msg the document it was prepared for is no longer active документът, за който е подготвено, вече не е активен @msg pattern missing липсва мостра @msg patterns nested too deeply are filled with mid-grey мострите, вложени прекалено дълбоко, се запълват със средно сиво @msg percent must be above 0 and at most 6400, not {p} percent трябва да е над 0 и най-много 6400, а не {p} @@ -5408,6 +5411,8 @@ lighting Light Светлина Pen Перо Pentagram Петограм Perceptual Перцептивен + Hue Clockwise Оттенък по часовниковата стрелка + Hue Counterclockwise Оттенък обратно на часовниковата стрелка Phone 360×800 Телефон 360×800 Phone 375×667 Телефон 375×667 Phone 375×812 Телефон 375×812 @@ -5977,7 +5982,7 @@ axis Y Axis Y ос @msg `dither` applies to gradient fills (stroke: false) `dither` се прилага към градиентни запълки (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` се прилага към градиентни запълки (paint.setFill) @msg `dither` must be true or false `dither` трябва да бъде true или false -@msg `method` must be classic or perceptual `method` трябва да е classic или perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` трябва да е classic, perceptual, hue-clockwise или hue-counterclockwise # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Да се инсталира ли „{name}“, който иска голям изчислителен бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Всяко изпълнение може да продължи до {minutes} минути и да свърши {times} пъти повече работа, отколкото е позволено на другите приставки. То се изпълнява във фонов режим и можете да го откажете. @@ -6018,6 +6023,7 @@ axis Y Axis Y ос Continue Продължаване Continue Action Продължаване на действието Delete Action Step Изтриване на стъпка от действието + Move Action Step Преместване на стъпка от действието Press Play Current Selection in the Actions panel to go on. За да продължите, натиснете „Изпълнение на текущия избор“ в панела „Действия“. Stop: {message} Спиране: {message} @msg no action is waiting at a stop няма действие, което чака при спиране @@ -6178,6 +6184,22 @@ axis Y Axis Y ос Graphite Pencil Графитен молив Wax Crayon Восъчен пастел Charcoal Dust Въглен прах + Notched Ribbon Назъбена лента + Double Line Двойна линия + Wave Вълна + Teardrop Сълза + Striped Ribbon Раирана лента + Fine Nib Тънък писец + Broad Nib Широк писец + Chisel Длето + Round Pen Кръгло перо + Pressure Pen Перо с натиск + Beads Мъниста + Dash Dot Тире-точка + Scallops Фестони + Stars Звезди + Triangles Триъгълници + Sparkles Искри Save Brush Library… Запази библиотека с четки… @msg no empty artboards to delete няма празни работни области за изтриване Scale Presets Пресети за мащаб @@ -6189,6 +6211,13 @@ axis Y Axis Y ос Area Text Текст в зона # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Измерване на текста по границите на глифа +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Опции за прилепване към пиксел + Pixel Snapping Options… Опции за прилепване към пиксел… + Snap to Pixel While Drawing Прилепване към пиксел при рисуване + Snap to Pixel While Moving Прилепване към пиксел при преместване + Snap to Pixel While Scaling Прилепване към пиксел при мащабиране + These options take effect when View › Snap to Pixel is on. Тези опции действат, когато Изглед › Прилепване към пиксел е включено. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показване на опциите за прилепване към глиф Hide Snap to Glyph Options Скриване на опциите за прилепване към глиф diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ca1d2c9420..652b002d11 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1813,6 +1813,8 @@ digits Malayalam Malajalam Objects on Path Tool Alat objekti na putanji Attach Objects on Path Priloži objekte na putanju Expand Objects on Path Proširi objekte na putanji + Detach Odvoji + Detach Object from Path Odvoji objekat s putanje Release Live Paint Oslobodi živo bojenje Release Repeat Oslobodi ponavljanje Release Slice Oslobodi isječak @@ -4914,6 +4916,7 @@ imageTrace Outline Obris @msg paths contain invalid coordinates putanje sadrže nevažeće koordinate @msg paths: give at least one file paths: navedite barem jednu datoteku @msg replace: the object to replace was changed or deleted replace: objekat za zamjenu je promijenjen ili izbrisan +@msg the document it was prepared for is no longer active dokument za koji je pripremljeno više nije aktivan @msg pattern missing uzorak nedostaje @msg patterns nested too deeply are filled with mid-grey previše duboko ugniježđeni uzorci ispunjavaju se srednje sivom @msg percent must be above 0 and at most 6400, not {p} percent mora biti iznad 0 i najviše 6400, a ne {p} @@ -4949,6 +4952,7 @@ imageTrace Outline Obris @msg select a repeat odaberite ponavljanje @msg select an Intertwine group odaberite Intertwine grupu @msg select an Objects on Path group odaberite grupu objekata na putanji +@msg choose the object to detach with its Select/Move widget odaberite objekat koji želite odvojiti njegovom ručicom Odaberi/Pomjeri @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects putanja unutar složene putanje, stapanja, omotnice, ponavljanja ili Intertwine grupe ne može primiti objekte @msg a clipping path can't take objects putanja isijecanja ne može primiti objekte @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekti unutar stapanja, omotnice, ponavljanja ili Intertwine grupe ne mogu se priložiti putanji @@ -5504,6 +5508,8 @@ imageTrace Outline Obris Vivid Živo Spectrum Spektar Perceptual Perceptivno + Hue Clockwise Nijansa u smjeru kazaljke na satu + Hue Counterclockwise Nijansa suprotno od kazaljke na satu Relative Colorimetric Relativna kolorimetrija Absolute Colorimetric Apsolutna kolorimetrija Exact Tačno @@ -5995,7 +6001,7 @@ axis Y Axis Y osa @msg `dither` applies to gradient fills (stroke: false) `dither` se primjenjuje na gradijentne ispune (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` se primjenjuje na gradijentne ispune (paint.setFill) @msg `dither` must be true or false `dither` mora biti true ili false -@msg `method` must be classic or perceptual `method` mora biti classic ili perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` mora biti classic, perceptual, hue-clockwise ili hue-counterclockwise Install “{name}”, which asks for a heavy budget? Instalirati „{name}”, koji traži veliki budžet? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Svako pokretanje može trajati do {minutes} minuta i obaviti {times} puta više posla nego drugi dodaci. Radi u pozadini i možete ga otkazati. It may take minutes, so it runs in the background, where you can cancel it. Može trajati minutama, pa radi u pozadini, gdje ga možete otkazati. @@ -6059,6 +6065,7 @@ axis Y Axis Y osa Continue Nastavi Continue Action Nastavi akciju Delete Action Step Izbriši korak akcije + Move Action Step Premjesti korak akcije Press Play Current Selection in the Actions panel to go on. Za nastavak pritisnite „Reprodukuj trenutni odabir“ na panelu Akcije. Stop: {message} Zaustavljanje: {message} @msg no action is waiting at a stop nijedna akcija ne čeka na zaustavljanju @@ -6230,6 +6237,22 @@ axis Y Axis Y osa Graphite Pencil Grafitna olovka Wax Crayon Voštana bojica Charcoal Dust Ugljena prašina + Notched Ribbon Zarezana traka + Double Line Dvostruka linija + Wave Talas + Teardrop Suza + Striped Ribbon Prugasta traka + Fine Nib Tanki vrh + Broad Nib Široki vrh + Chisel Dlijeto + Round Pen Okruglo pero + Pressure Pen Pero na pritisak + Beads Perle + Dash Dot Crta-tačka + Scallops Festoni + Stars Zvijezde + Triangles Trouglovi + Sparkles Iskrice Save Brush Library… Sačuvaj biblioteku četkica… @msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu @msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries) @@ -6247,6 +6270,12 @@ axis Y Axis Y osa Area Text Tekst u oblasti # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mjeri tekst po granicama glifa + Pixel Snapping Options Opcije prianjanja uz piksel + Pixel Snapping Options… Opcije prianjanja uz piksel… + Snap to Pixel While Drawing Prilijepi uz piksel pri crtanju + Snap to Pixel While Moving Prilijepi uz piksel pri pomjeranju + Snap to Pixel While Scaling Prilijepi uz piksel pri skaliranju + These options take effect when View › Snap to Pixel is on. Ove opcije djeluju kada je uključeno Prikaz › Prilijepi uz piksel. Show Snap to Glyph Options Prikaži opcije prianjanja uz glif Hide Snap to Glyph Options Sakrij opcije prianjanja uz glif Snap to Glyph Options Opcije prianjanja uz glif diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b038cda60c..6ab33aacbb 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1546,6 +1546,8 @@ antiAlias Sharp Nítid Expand Live Paint Expandeix la pintura interactiva Expand Object Expandeix l’objecte Expand Objects on Path Expandeix els objectes del traçat + Detach Separa + Detach Object from Path Separa l’objecte del traçat Expand Options Opcions d’expansió Expand Panels Expandeix els panells Expand Repeat Expandeix la repetició @@ -3142,6 +3144,8 @@ digits Malayalam Malaiàlam per slice per tall Percent: Percentatge: Perceptual Perceptual + Hue Clockwise To en sentit horari + Hue Counterclockwise To en sentit antihorari Perceptual Scales Escales perceptuals Performance Rendiment Permissions Permisos @@ -3931,6 +3935,22 @@ digits Malayalam Malaiàlam Graphite Pencil Llapis de grafit Wax Crayon Cera de colors Charcoal Dust Pols de carbó + Notched Ribbon Cinta dentada + Double Line Línia doble + Wave Ona + Teardrop Llàgrima + Striped Ribbon Cinta ratllada + Fine Nib Plomí fi + Broad Nib Plomí ample + Chisel Escarpra + Round Pen Ploma rodona + Pressure Pen Ploma de pressió + Beads Perles + Dash Dot Ratlla i punt + Scallops Festons + Stars Estrelles + Triangles Triangles + Sparkles Espurnes Scatter: Dispersió: Scissors Tisores Scissors Tool Eina de tisores @@ -4057,6 +4077,7 @@ digits Malayalam Malaiàlam Continue Continua Continue Action Continua l’acció Delete Action Step Suprimeix el pas de l’acció + Move Action Step Mou el pas de l’acció Press Play Current Selection in the Actions panel to go on. Per continuar, premeu «Reprodueix la selecció actual» al tauler Accions. Stop: {message} Aturada: {message} @msg no action is waiting at a stop cap acció no espera en una aturada @@ -5199,7 +5220,7 @@ digits Malayalam Malaiàlam @msg `ids` must be an array of object ids, not {v} `ids` ha de ser una matriu d’identificadors d’objecte, no {v} @msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v} @msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list}) -@msg `method` must be classic or perceptual `method` ha de ser classic o perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` ha de ser classic, perceptual, hue-clockwise o hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` ha de ser pad, repeat o reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` ha de ser un nombre enter d’1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’aplica als degradats lineals, radials, cònics i de rombe @@ -5761,6 +5782,7 @@ digits Malayalam Malaiàlam @msg the library has another item of that kind named `{name}` la biblioteca ja té un altre element d’aquest tipus anomenat `{name}` @msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list}) @msg No linked files to look for No hi ha fitxers enllaçats per buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç @msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt @msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt @msg no native format no hi ha format natiu @@ -5971,6 +5993,7 @@ digits Malayalam Malaiàlam @msg Reload the page to go on (changes not saved yet may be lost). Recarrega la pàgina per continuar (els canvis encara no desats es poden perdre). @msg Replace Spine: select a blend and a path Substitueix l’espina: selecciona una fusió i un traçat @msg replace: the object to replace was changed or deleted replace: l’objecte que s’havia de substituir s’ha canviat o suprimit +@msg the document it was prepared for is no longer active el document per al qual es va preparar ja no està actiu @msg Reset {_1} Restableix {_1} @msg resolutions must be between 1 and 2400 ppi les resolucions han de ser entre 1 i 2400 ppi @msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them S’ha restaurat {n} document recuperat: desa’l per conservar-lo|S’han restaurat {n} documents recuperats: desa’ls per conservar-los @@ -6026,6 +6049,7 @@ digits Malayalam Malaiàlam @msg select an Intertwine group selecciona un grup Intertwine @msg select an object in perspective selecciona un objecte en perspectiva @msg select an Objects on Path group selecciona un grup d’objectes sobre un traçat +@msg choose the object to detach with its Select/Move widget tria l’objecte que vols separar amb el seu control Selecciona/Mou @msg select artwork (or a colour group) to recolor selecciona la il·lustració (o un grup de colors) per recolorejar @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloració completada; sense canvis: {locked} i {hidden} @msg Recolor completed; {objects} were left unchanged Recoloració completada; sense canvis: {objects} @@ -6121,7 +6145,9 @@ digits Malayalam Malaiàlam @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` ha de ser front o back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` ha de ser un nombre de {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` ha de ser una matriu -@msg Graphics map onto planar faces: use the Extrude or Plane object type Els gràfics s’apliquen a cares planes: fes servir el tipus d’objecte Extrusió o Pla +@msg Graphics map onto the Plane, Extrude and Revolve object types Els gràfics s’apliquen als tipus d’objecte Pla, Extrusió i Revolució +@msg Revolve maps graphics onto its side: map them onto the front Revoluciona aplica els gràfics al seu lateral: aplica’ls a la cara davantera +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Els gràfics són massa complexos per a aquesta superfície: simplifica la il·lustració dels símbols o redueix Segments @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admet com a màxim {MAX_GRAPHICS} gràfics @msg A plane has one face: map its graphics onto the front Un pla té una sola cara: aplica’n els gràfics a la cara davantera @msg The graphics are too complex: simplify the symbols' art Els gràfics són massa complexos: simplifica la il·lustració dels símbols @@ -6705,6 +6731,13 @@ theme Appearance Aparença Area Text Text d’àrea # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mesura el text pels límits del glif +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opcions d’ajust al píxel + Pixel Snapping Options… Opcions d’ajust al píxel… + Snap to Pixel While Drawing Ajusta al píxel en dibuixar + Snap to Pixel While Moving Ajusta al píxel en moure + Snap to Pixel While Scaling Ajusta al píxel en escalar + These options take effect when View › Snap to Pixel is on. Aquestes opcions tenen efecte quan Visualitza › Ajusta al píxel està activat. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra les opcions d’ajust al glif Hide Snap to Glyph Options Amaga les opcions d’ajust al glif diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5a33ceff34..d708c8b8b2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1844,6 +1844,8 @@ digits Malayalam Malajálamské Objects on Path Tool Nástroj Objekty na cestě Attach Objects on Path Připojit objekty na cestu Expand Objects on Path Rozložit objekty na cestě + Detach Odpojit + Detach Object from Path Odpojit objekt od cesty Release Live Paint Uvolnit živou malbu Release Repeat Uvolnit opakování Release Slice Uvolnit řez @@ -5081,6 +5083,7 @@ imageTrace Outline Obrys @msg paths contain invalid coordinates cesty obsahují neplatné souřadnice @msg paths: give at least one file paths: zadejte alespoň jeden soubor @msg replace: the object to replace was changed or deleted replace: objekt k nahrazení byl změněn nebo odstraněn +@msg the document it was prepared for is no longer active dokument, pro který bylo připraveno, už není aktivní @msg pattern missing chybí vzorek @msg patterns nested too deeply are filled with mid-grey příliš hluboko vnořené vzorky se vyplní středně šedou @msg percent must be above 0 and at most 6400, not {p} percent musí být větší než 0 a nejvýše 6400, ne {p} @@ -5116,6 +5119,7 @@ imageTrace Outline Obrys @msg select a repeat vyberte opakování @msg select an Intertwine group vyberte skupinu propletení @msg select an Objects on Path group vyberte skupinu objektů na cestě +@msg choose the object to detach with its Select/Move widget vyberte objekt k odpojení jeho ovládacím prvkem Vybrat/Přesunout @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects cesta uvnitř složené cesty, prolnutí, obálky, opakování nebo skupiny propletení nemůže přijmout objekty @msg a clipping path can't take objects ořezová cesta nemůže přijmout objekty @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekty uvnitř prolnutí, obálky, opakování nebo skupiny propletení nelze připojit na cestu @@ -5440,6 +5444,7 @@ imageTrace Outline Obrys @msg every numbered name for it is taken in the Fonts folder ve složce Fonts jsou obsazena všechna číslovaná jména pro tento soubor @msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} @msg No linked files to look for Žádné propojené soubory k vyhledání +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} @@ -5714,6 +5719,8 @@ imageTrace Outline Obrys Spectrum Spektrum # Rendering intents, recolor methods, gradient kinds Perceptual Percepční + Hue Clockwise Odstín ve směru hodinových ručiček + Hue Counterclockwise Odstín proti směru hodinových ručiček Relative Colorimetric Relativní kolorimetrický Absolute Colorimetric Absolutní kolorimetrický Exact Přesná @@ -5963,7 +5970,9 @@ mark-style Japanese Japonské @msg `{GRAPHICS}` face must be front or back face v `{GRAPHICS}` musí být front nebo back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} v `{GRAPHICS}` musí být číslo od {lo} do {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` musí být pole -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafika se mapuje na rovinné stěny: použijte typ objektu Vysunutí nebo Rovina +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafika se mapuje na typy objektu Rovina, Vysunutí a Rotační těleso +@msg Revolve maps graphics onto its side: map them onto the front Rotační těleso mapuje grafiku na svůj bok: umístěte ji na přední stranu +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafika je pro tento povrch příliš složitá: zjednodušte kresbu symbolů nebo snižte Segmenty @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` přijímá nejvýše {MAX_GRAPHICS} grafik @msg A plane has one face: map its graphics onto the front Rovina má jednu stranu: umístěte její grafiku na přední stranu @msg The graphics are too complex: simplify the symbols' art Grafika je příliš složitá: zjednodušte kresbu symbolů @@ -6309,7 +6318,7 @@ axis Y Axis osa Y @msg `dither` applies to gradient fills (stroke: false) `dither` platí pro výplně přechodem (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill) @msg `dither` must be true or false `dither` musí být true nebo false -@msg `method` must be classic or perceptual `method` musí být classic nebo perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` musí být classic, perceptual, hue-clockwise nebo hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` musí být pad, repeat nebo reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musí být celé číslo od 1 do {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` platí pro lineární, kruhové, kuželové a kosočtverečné přechody @@ -6552,6 +6561,7 @@ axis Y Axis osa Y Continue Pokračovat Continue Action Pokračovat v akci Delete Action Step Odstranit krok akce + Move Action Step Přesunout krok akce Press Play Current Selection in the Actions panel to go on. Chcete-li pokračovat, stiskněte na panelu Akce tlačítko Přehrát aktuální výběr. Stop: {message} Zastavení: {message} @msg no action is waiting at a stop žádná akce nečeká na zastavení @@ -6723,6 +6733,22 @@ axis Y Axis osa Y Graphite Pencil Grafitová tužka Wax Crayon Vosková pastelka Charcoal Dust Uhlový prach + Notched Ribbon Vroubkovaná stuha + Double Line Dvojitá čára + Wave Vlna + Teardrop Slza + Striped Ribbon Pruhovaná stuha + Fine Nib Jemný hrot + Broad Nib Široký hrot + Chisel Dláto + Round Pen Kulaté pero + Pressure Pen Tlakové pero + Beads Korálky + Dash Dot Čárka-tečka + Scallops Feston + Stars Hvězdy + Triangles Trojúhelníky + Sparkles Jiskry Save Brush Library… Uložit knihovnu štětců… @msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití @msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries) @@ -6742,6 +6768,13 @@ axis Y Axis osa Y Area Text Text v oblasti # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Měřit text podle hranic glyfů +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Možnosti přichytávání k obrazovým bodům + Pixel Snapping Options… Možnosti přichytávání k obrazovým bodům… + Snap to Pixel While Drawing Přichytávat k obrazovým bodům při kreslení + Snap to Pixel While Moving Přichytávat k obrazovým bodům při přesouvání + Snap to Pixel While Scaling Přichytávat k obrazovým bodům při změně velikosti + These options take effect when View › Snap to Pixel is on. Tyto volby se uplatní, když je zapnuto Zobrazení › Přichytávat k obrazovým bodům. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Zobrazit možnosti přichytávání ke glyfům Hide Snap to Glyph Options Skrýt možnosti přichytávání ke glyfům diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index eaa29db837..0fff0e3fda 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4619,6 +4619,7 @@ imageTrace Outline Kontur @msg paths contain invalid coordinates stierne indeholder ugyldige koordinater @msg paths: give at least one file stier: angiv mindst én fil @msg replace: the object to replace was changed or deleted erstat: objektet, der skulle erstattes, er ændret eller slettet +@msg the document it was prepared for is no longer active dokumentet, det blev forberedt til, er ikke længere aktivt @msg pattern missing mønster mangler @msg patterns nested too deeply are filled with mid-grey mønstre, der er indlejret for dybt, udfyldes med mellemgrå @msg percent must be above 0 and at most 6400, not {p} percent skal være over 0 og højst 6400, ikke {p} @@ -5181,6 +5182,8 @@ imageTrace Outline Kontur Vivid Intens Spectrum Spektrum Perceptual Perceptuel + Hue Clockwise Farvetone med uret + Hue Counterclockwise Farvetone mod uret Relative Colorimetric Relativ kolorimetrisk Absolute Colorimetric Absolut kolorimetrisk Exact Eksakt @@ -5846,6 +5849,8 @@ swatch Bright Violet Kraftig violet Objects on Path Tool Objekter på kurve-værktøj Attach Objects on Path Tilknyt objekter til kurve Expand Objects on Path Udvid objekter på kurve + Detach Frigør + Detach Object from Path Frigør objekt fra kurve Release Live Paint Frigør live-maling Release Repeat Frigør gentagelse Release Slice Frigør udsnit @@ -5993,7 +5998,7 @@ axis Y Axis Y-akse @msg `dither` applies to gradient fills (stroke: false) `dither` gælder for gradientfyld (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` gælder for gradientfyld (paint.setFill) @msg `dither` must be true or false `dither` skal være true eller false -@msg `method` must be classic or perceptual `method` skal være classic eller perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` skal være classic, perceptual, hue-clockwise eller hue-counterclockwise # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installér »{name}«, som beder om et stort beregningsbudget? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Hver kørsel kan tage op til {minutes} minutter og udføre {times} gange så meget arbejde, som andre plug-ins må. Den kører i baggrunden, og du kan annullere den. @@ -6034,6 +6039,7 @@ axis Y Axis Y-akse Continue Fortsæt Continue Action Fortsæt handling Delete Action Step Slet handlingstrin + Move Action Step Flyt handlingstrin Press Play Current Selection in the Actions panel to go on. Tryk på Afspil aktuel markering i panelet Handlinger for at fortsætte. Stop: {message} Stop: {message} @msg no action is waiting at a stop ingen handling venter ved et stop @@ -6194,6 +6200,22 @@ axis Y Axis Y-akse Graphite Pencil Grafitblyant Wax Crayon Vokskridt Charcoal Dust Kulstøv + Notched Ribbon Takket bånd + Double Line Dobbelt linje + Wave Bølge + Teardrop Dråbe + Striped Ribbon Stribet bånd + Fine Nib Fin pen + Broad Nib Bred pen + Chisel Mejsel + Round Pen Rund pen + Pressure Pen Trykpen + Beads Perler + Dash Dot Streg-prik + Scallops Bølgekant + Stars Stjerner + Triangles Trekanter + Sparkles Gnister Save Brush Library… Gem penselbibliotek… @msg no empty artboards to delete ingen tomme tegneflader at slette Scale Presets Skaleringsforudindstillinger @@ -6205,6 +6227,13 @@ axis Y Axis Y-akse Area Text Områdetekst # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mål tekst efter glyfafgrænsning +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Indstillinger for fastgørelse til pixel + Pixel Snapping Options… Indstillinger for fastgørelse til pixel… + Snap to Pixel While Drawing Fastgør til pixel ved tegning + Snap to Pixel While Moving Fastgør til pixel ved flytning + Snap to Pixel While Scaling Fastgør til pixel ved skalering + These options take effect when View › Snap to Pixel is on. Disse indstillinger virker, når Vis › Fastgør til pixel er slået til. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Vis indstillinger for fastgørelse til tegn Hide Snap to Glyph Options Skjul indstillinger for fastgørelse til tegn diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0fb27f2c8a..711a01125c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1592,6 +1592,8 @@ file-filter Templates Vorlagen Objects on Path Tool Objekte-auf-Pfad-Werkzeug Attach Objects on Path Objekte an Pfad anhängen Expand Objects on Path Objekte auf Pfad umwandeln + Detach Lösen + Detach Object from Path Objekt vom Pfad lösen Release Live Paint „Interaktiv malen“ zurückwandeln Release Repeat Wiederholung zurückwandeln Release Slice Slice zurückwandeln @@ -4697,6 +4699,7 @@ theme Appearance Erscheinungsbild @msg paths contain invalid coordinates die Pfade enthalten ungültige Koordinaten @msg paths: give at least one file paths: Geben Sie mindestens eine Datei an @msg replace: the object to replace was changed or deleted replace: Das zu ersetzende Objekt wurde geändert oder gelöscht +@msg the document it was prepared for is no longer active das Dokument, für das es vorbereitet wurde, ist nicht mehr aktiv @msg pattern missing das Muster fehlt @msg patterns nested too deeply are filled with mid-grey zu tief verschachtelte Muster werden mit mittlerem Grau gefüllt @msg percent must be above 0 and at most 6400, not {p} percent muss über 0 und höchstens 6400 sein, nicht {p} @@ -4732,6 +4735,7 @@ theme Appearance Erscheinungsbild @msg select a repeat wählen Sie eine Wiederholung aus @msg select an Intertwine group wählen Sie eine Verflechtungsgruppe aus @msg select an Objects on Path group wählen Sie eine Gruppe „Objekte auf Pfad“ aus +@msg choose the object to detach with its Select/Move widget wählen Sie das zu lösende Objekt mit seinem Steuerelement „Auswählen/Verschieben“ aus @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects ein Pfad in einem zusammengesetzten Pfad, einer Angleichung, einer Hülle, einer Wiederholung oder einer Verflechtungsgruppe kann keine Objekte aufnehmen @msg a clipping path can't take objects ein Schnittpfad kann keine Objekte aufnehmen @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path die Objekte in einer Angleichung, einer Hülle, einer Wiederholung oder einer Verflechtungsgruppe können nicht an einen Pfad angehängt werden @@ -5056,6 +5060,7 @@ theme Appearance Erscheinungsbild @msg every numbered name for it is taken in the Fonts folder alle nummerierten Namen dafür sind im Schriftenordner bereits vergeben @msg every numbered name for {name} is taken in the document's folder alle nummerierten Namen für {name} sind im Ordner des Dokuments bereits vergeben @msg No linked files to look for Keine verknüpften Dateien zum Suchen +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Die Verknüpfung befindet sich in einem Symbol, einem Diagrammdesign, einem Muster oder einer Deckkraftmaske und hat keinen Platz auf der Zeichenfläche # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Dienste @@ -5539,7 +5544,9 @@ lighting Light Licht @msg `{GRAPHICS}` face must be front or back face in `{GRAPHICS}` muss front oder back sein @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} in `{GRAPHICS}` muss eine Zahl von {lo} bis {hi} sein @msg `{GRAPHICS}` must be an array `{GRAPHICS}` muss ein Array sein -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiken werden auf ebene Seiten abgebildet: Verwenden Sie den Objekttyp Extrudieren oder Ebene +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafiken werden auf die Objekttypen Ebene, Extrudieren und Kreiseln abgebildet +@msg Revolve maps graphics onto its side: map them onto the front Kreiseln bildet Grafiken auf seiner Mantelfläche ab: Bilden Sie sie auf der Vorderseite ab +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Die Grafiken sind für diese Oberfläche zu komplex: Vereinfachen Sie das Bildmaterial der Symbole oder verringern Sie Segmente @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nimmt höchstens {MAX_GRAPHICS} Grafiken auf @msg A plane has one face: map its graphics onto the front Eine Ebene hat nur eine Seite: Bilden Sie ihre Grafiken auf der Vorderseite ab @msg The graphics are too complex: simplify the symbols' art Die Grafiken sind zu komplex: Vereinfachen Sie das Bildmaterial der Symbole @@ -5901,7 +5908,9 @@ axis Y Axis Y-Achse @msg `dither` applies to gradient fills (paint.setFill) `dither` gilt für Verlaufsflächen (paint.setFill) @msg `dither` must be true or false `dither` muss true oder false sein Perceptual Perzeptiv -@msg `method` must be classic or perceptual `method` muss classic oder perceptual sein + Hue Clockwise Farbton im Uhrzeigersinn + Hue Counterclockwise Farbton gegen den Uhrzeigersinn +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` muss classic, perceptual, hue-clockwise oder hue-counterclockwise sein @msg `spread` must be pad, repeat or reflect `spread` muss pad, repeat oder reflect sein @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` muss eine ganze Zahl von 1 bis {MAX_REPEATS} sein @msg `spread` applies to linear, radial, conic and diamond gradients `spread` gilt für lineare, radiale, konische Verläufe und Rautenverläufe @@ -6144,6 +6153,7 @@ axis Y Axis Y-Achse Continue Fortsetzen Continue Action Aktion fortsetzen Delete Action Step Aktionsschritt löschen + Move Action Step Aktionsschritt verschieben Press Play Current Selection in the Actions panel to go on. Klicken Sie im Bedienfeld „Aktionen“ auf „Aktuelle Auswahl abspielen“, um fortzufahren. Stop: {message} Stopp: {message} @msg no action is waiting at a stop keine Aktion wartet an einem Stopp @@ -6315,6 +6325,22 @@ axis Y Axis Y-Achse Graphite Pencil Graphitstift Wax Crayon Wachsmalstift Charcoal Dust Kohlestaub + Notched Ribbon Gekerbtes Band + Double Line Doppellinie + Wave Welle + Teardrop Tropfen + Striped Ribbon Gestreiftes Band + Fine Nib Feine Feder + Broad Nib Breite Feder + Chisel Meißel + Round Pen Runde Feder + Pressure Pen Druckfeder + Beads Perlen + Dash Dot Strich-Punkt + Scallops Bogenkante + Stars Sterne + Triangles Dreiecke + Sparkles Funkeln Save Brush Library… Pinsel-Bibliothek speichern… @msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden @msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries) @@ -6334,6 +6360,13 @@ axis Y Axis Y-Achse Area Text Flächentext # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Text an Glyphenbegrenzung messen +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Optionen für „An Pixel ausrichten“ + Pixel Snapping Options… Optionen für „An Pixel ausrichten“… + Snap to Pixel While Drawing Beim Zeichnen an Pixel ausrichten + Snap to Pixel While Moving Beim Verschieben an Pixel ausrichten + Snap to Pixel While Scaling Beim Skalieren an Pixel ausrichten + These options take effect when View › Snap to Pixel is on. Diese Optionen wirken, wenn Ansicht › An Pixel ausrichten aktiviert ist. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Optionen für „An Glyphe ausrichten“ einblenden Hide Snap to Glyph Options Optionen für „An Glyphe ausrichten“ ausblenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 10df756219..0088ec5313 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1512,6 +1512,8 @@ font-technology Variable Μεταβλητές Objects on Path Tool Αντικείμενα σε διαδρομή (Objects on Path Tool) Attach Objects on Path Προσάρτηση αντικειμένων σε διαδρομή Expand Objects on Path Επέκταση αντικειμένων σε διαδρομή + Detach Αποσύνδεση + Detach Object from Path Αποσύνδεση αντικειμένου από διαδρομή Release Live Paint Αποδέσμευση δυναμικής βαφής Release Repeat Αποδέσμευση επανάληψης Release Slice Αποδέσμευση τομής @@ -2746,6 +2748,8 @@ lighting Light Φως Oval Οβάλ Pen Πένα (Pen Tool) Perceptual Αντιληπτικό + Hue Clockwise Απόχρωση δεξιόστροφα + Hue Counterclockwise Απόχρωση αριστερόστροφα Pixel Εικονοστοιχείο Points: Σημεία: Preserve Διατήρηση @@ -4727,6 +4731,7 @@ digits Malayalam Μαλαγιάλαμ @msg paths contain invalid coordinates οι διαδρομές περιέχουν μη έγκυρες συντεταγμένες @msg paths: give at least one file διαδρομές: δώστε τουλάχιστον ένα αρχείο @msg replace: the object to replace was changed or deleted αντικατάσταση: το αντικείμενο προς αντικατάσταση άλλαξε ή διαγράφηκε +@msg the document it was prepared for is no longer active το έγγραφο για το οποίο προετοιμάστηκε δεν είναι πλέον ενεργό @msg pattern missing λείπει το μοτίβο @msg patterns nested too deeply are filled with mid-grey τα πολύ βαθιά ένθετα μοτίβα γεμίζονται με μέτριο γκρι @msg percent must be above 0 and at most 6400, not {p} το percent πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p} @@ -5080,6 +5085,7 @@ digits Malayalam Μαλαγιάλαμ @msg every numbered name for it is taken in the Fonts folder όλα τα αριθμημένα ονόματά του χρησιμοποιούνται ήδη στον φάκελο γραμματοσειρών @msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου @msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά Hide VectorCraft Απόκρυψη VectorCraft Bring All to Front Μεταφορά όλων στο προσκήνιο Break Link Κατάργηση σύνδεσης @@ -5323,7 +5329,9 @@ digits Malayalam Μαλαγιάλαμ @msg `{GRAPHICS}` face must be front or back το face του `{GRAPHICS}` πρέπει να είναι front ή back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} το {key} του `{GRAPHICS}` πρέπει να είναι αριθμός από {lo} έως {hi} @msg `{GRAPHICS}` must be an array το `{GRAPHICS}` πρέπει να είναι πίνακας -@msg Graphics map onto planar faces: use the Extrude or Plane object type Τα γραφικά αντιστοιχίζονται σε επίπεδες όψεις: χρησιμοποιήστε τον τύπο αντικειμένου Εξώθηση ή Επίπεδο +@msg Graphics map onto the Plane, Extrude and Revolve object types Τα γραφικά αντιστοιχίζονται στους τύπους αντικειμένου Επίπεδο, Εξώθηση και Περιστροφή γύρω από άξονα +@msg Revolve maps graphics onto its side: map them onto the front Η Περιστροφή γύρω από άξονα αντιστοιχίζει τα γραφικά στην πλευρική της επιφάνεια: αντιστοιχίστε τα στην μπροστινή όψη +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Τα γραφικά είναι πολύ σύνθετα για αυτή την επιφάνεια: απλοποιήστε το σχέδιο των συμβόλων ή μειώστε τα Τμήματα @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics το `{GRAPHICS}` δέχεται το πολύ {MAX_GRAPHICS} γραφικά @msg A plane has one face: map its graphics onto the front Ένα επίπεδο έχει μία όψη: αντιστοιχίστε τα γραφικά του στην μπροστινή όψη @msg The graphics are too complex: simplify the symbols' art Τα γραφικά είναι πολύ σύνθετα: απλοποιήστε το σχέδιο των συμβόλων @@ -6042,6 +6050,7 @@ effect Points Σημεία @msg no bound object reports a value (locked objects and embedded images report none) κανένα συνδεδεμένο αντικείμενο δεν αναφέρει τιμή (τα κλειδωμένα αντικείμενα και οι ενσωματωμένες εικόνες δεν αναφέρουν καμία) @msg select an Intertwine group επιλέξτε μια ομάδα διαπλοκής @msg select an Objects on Path group επιλέξτε μια ομάδα αντικειμένων σε διαδρομή +@msg choose the object to detach with its Select/Move widget επιλέξτε το αντικείμενο προς αποσύνδεση με το στοιχείο ελέγχου Επιλογή/Μετακίνηση @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects μια διαδρομή μέσα σε σύνθετη διαδρομή, ανάμειξη, περίβλημα, επανάληψη ή ομάδα διαπλοκής δεν μπορεί να δεχτεί αντικείμενα @msg a clipping path can't take objects μια διαδρομή αποκοπής δεν μπορεί να δεχτεί αντικείμενα @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path τα αντικείμενα μέσα σε ανάμειξη, περίβλημα, επανάληψη ή ομάδα διαπλοκής δεν μπορούν να προσαρτηθούν σε διαδρομή @@ -6261,7 +6270,7 @@ axis Y Axis Άξονας Y @msg `dither` applies to gradient fills (stroke: false) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill) @msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false -@msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise Το `method` πρέπει να είναι classic, perceptual, hue-clockwise ή hue-counterclockwise @msg `spread` must be pad, repeat or reflect Το `spread` πρέπει να είναι pad, repeat ή reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} Το `repeats` πρέπει να είναι ακέραιος από 1 έως {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients Το `spread` ισχύει για γραμμικά, ακτινικά, κωνικά και ρομβοειδή ντεγκραντέ @@ -6504,6 +6513,7 @@ axis Y Axis Άξονας Y Continue Συνέχεια Continue Action Συνέχεια ενέργειας Delete Action Step Διαγραφή βήματος ενέργειας + Move Action Step Μετακίνηση βήματος ενέργειας Press Play Current Selection in the Actions panel to go on. Για να συνεχίσετε, πατήστε «Αναπαραγωγή τρέχουσας επιλογής» στο πάνελ Ενέργειες. Stop: {message} Διακοπή: {message} @msg no action is waiting at a stop καμία ενέργεια δεν περιμένει σε διακοπή @@ -6675,6 +6685,22 @@ axis Y Axis Άξονας Y Graphite Pencil Μολύβι γραφίτη Wax Crayon Κηρομπογιά Charcoal Dust Σκόνη κάρβουνου + Notched Ribbon Οδοντωτή κορδέλα + Double Line Διπλή γραμμή + Wave Κύμα + Teardrop Δάκρυ + Striped Ribbon Ριγέ κορδέλα + Fine Nib Λεπτή πένα + Broad Nib Φαρδιά πένα + Chisel Σμίλη + Round Pen Στρογγυλή πένα + Pressure Pen Πένα πίεσης + Beads Χάντρες + Dash Dot Παύλα-τελεία + Scallops Φεστόνια + Stars Αστέρια + Triangles Τρίγωνα + Sparkles Σπίθες Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή @msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries) @@ -6694,6 +6720,13 @@ axis Y Axis Άξονας Y Area Text Κείμενο περιοχής # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Μέτρηση κειμένου με βάση τα όρια γλυφής +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Επιλογές προσκόλλησης σε εικονοστοιχείο + Pixel Snapping Options… Επιλογές προσκόλλησης σε εικονοστοιχείο… + Snap to Pixel While Drawing Προσκόλληση σε εικονοστοιχείο κατά τη σχεδίαση + Snap to Pixel While Moving Προσκόλληση σε εικονοστοιχείο κατά τη μετακίνηση + Snap to Pixel While Scaling Προσκόλληση σε εικονοστοιχείο κατά την αλλαγή κλίμακας + These options take effect when View › Snap to Pixel is on. Αυτές οι επιλογές ισχύουν όταν είναι ενεργή η επιλογή Προβολή › Προσκόλληση σε εικονοστοιχείο. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Εμφάνιση επιλογών προσκόλλησης σε γλυφή Hide Snap to Glyph Options Απόκρυψη επιλογών προσκόλλησης σε γλυφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 62b2365463..06e47cfb8b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1592,6 +1592,8 @@ font-technology Variable Variables Objects on Path Tool Herramienta Objetos en trazado Attach Objects on Path Adjuntar objetos al trazado Expand Objects on Path Expandir objetos en trazado + Detach Separar + Detach Object from Path Separar objeto del trazado Release Live Paint Soltar pintura interactiva Release Repeat Soltar repetición Release Slice Soltar sector @@ -4773,6 +4775,7 @@ axis Both Ambos @msg paths contain invalid coordinates los trazados contienen coordenadas no válidas @msg paths: give at least one file paths: indica al menos un archivo @msg replace: the object to replace was changed or deleted replace: se modificó o eliminó el objeto que se iba a reemplazar +@msg the document it was prepared for is no longer active el documento para el que se preparó ya no está activo @msg pattern missing falta el motivo @msg patterns nested too deeply are filled with mid-grey los motivos anidados a demasiada profundidad se rellenan de gris medio @msg percent must be above 0 and at most 6400, not {p} percent debe ser mayor que 0 y como máximo 6400, no {p} @@ -4808,6 +4811,7 @@ axis Both Ambos @msg select a repeat selecciona una repetición @msg select an Intertwine group selecciona un grupo entrelazado @msg select an Objects on Path group selecciona un grupo de objetos en trazado +@msg choose the object to detach with its Select/Move widget elige el objeto que quieres separar con su control Seleccionar/Mover @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un trazado dentro de un trazado compuesto, una fusión, una envolvente, una repetición o un grupo entrelazado no puede recibir objetos @msg a clipping path can't take objects un trazado de recorte no puede recibir objetos @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path los objetos dentro de una fusión, una envolvente, una repetición o un grupo entrelazado no se pueden adjuntar a un trazado @@ -5132,6 +5136,7 @@ axis Both Ambos @msg every numbered name for it is taken in the Fonts folder todos los nombres numerados para él ya existen en la carpeta Fonts @msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento @msg No linked files to look for No hay archivos vinculados que buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios @@ -5630,6 +5635,8 @@ effect Regular Regular High Contrast 3 Alto contraste 3 Pentagram Pentagrama Perceptual Perceptual + Hue Clockwise Tono en sentido horario + Hue Counterclockwise Tono en sentido antihorario Relative Colorimetric Colorimétrico relativo Absolute Colorimetric Colorimétrico absoluto Exact Exacto @@ -5904,7 +5911,10 @@ mark-style Japanese Japonesas @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` debe ser front o back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` debe ser un número de {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` debe ser una matriz -@msg Graphics map onto planar faces: use the Extrude or Plane object type Los gráficos se asignan a caras planas: use el tipo de objeto Extrusión o Plano +@msg Graphics map onto the Plane, Extrude and Revolve object types Los gráficos se asignan a los tipos de objeto Plano, Extrusión y Revolución + Graphics map onto the Plane, Extrude and Revolve object types. Los gráficos se asignan a los tipos de objeto Plano, Extrusión y Revolución. +@msg Revolve maps graphics onto its side: map them onto the front Revolución asigna los gráficos a su lateral: asígnelos a la cara frontal +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Los gráficos son demasiado complejos para esta superficie: simplifique la ilustración de los símbolos o reduzca Segmentos @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admite como máximo {MAX_GRAPHICS} gráficos @msg A plane has one face: map its graphics onto the front Un plano tiene una sola cara: asigne sus gráficos a la cara frontal @msg The graphics are too complex: simplify the symbols' art Los gráficos son demasiado complejos: simplifique la ilustración de los símbolos @@ -6251,7 +6261,7 @@ axis Y Axis Eje Y @msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a rellenos de degradado (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill) @msg `dither` must be true or false `dither` debe ser true o false -@msg `method` must be classic or perceptual `method` debe ser classic o perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` debe ser classic, perceptual, hue-clockwise o hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` debe ser pad, repeat o reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` debe ser un número entero de 1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a los degradados lineales, radiales, cónicos y de rombo @@ -6494,6 +6504,7 @@ axis Y Axis Eje Y Continue Continuar Continue Action Continuar acción Delete Action Step Eliminar paso de la acción + Move Action Step Mover paso de la acción Press Play Current Selection in the Actions panel to go on. Para continuar, pulse Reproducir la selección actual en el panel Acciones. Stop: {message} Parada: {message} @msg no action is waiting at a stop ninguna acción está esperando en una parada @@ -6665,6 +6676,22 @@ axis Y Axis Eje Y Graphite Pencil Lápiz de grafito Wax Crayon Cera de colores Charcoal Dust Polvo de carboncillo + Notched Ribbon Cinta dentada + Double Line Línea doble + Wave Onda + Teardrop Lágrima + Striped Ribbon Cinta a rayas + Fine Nib Plumilla fina + Broad Nib Plumilla ancha + Chisel Cincel + Round Pen Pluma redonda + Pressure Pen Pluma de presión + Beads Cuentas + Dash Dot Raya y punto + Scallops Festones + Stars Estrellas + Triangles Triángulos + Sparkles Destellos Save Brush Library… Guardar biblioteca de pinceles… @msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) @@ -6684,6 +6711,13 @@ axis Y Axis Eje Y Area Text Texto de área # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Medir el texto por los límites del glifo +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opciones de ajuste a píxel + Pixel Snapping Options… Opciones de ajuste a píxel… + Snap to Pixel While Drawing Ajustar a píxel al dibujar + Snap to Pixel While Moving Ajustar a píxel al mover + Snap to Pixel While Scaling Ajustar a píxel al escalar + These options take effect when View › Snap to Pixel is on. Estas opciones se aplican cuando Ver › Ajustar a píxel está activado. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar opciones de ajuste a glifo Hide Snap to Glyph Options Ocultar opciones de ajuste a glifo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d881135799..2f133e3c6a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1817,6 +1817,8 @@ digits Malayalam Malajalam Objects on Path Tool Objektit polulla -työkalu Attach Objects on Path Kiinnitä objektit polulle Expand Objects on Path Laajenna objektit polulla + Detach Irrota + Detach Object from Path Irrota objekti polulta Release Live Paint Vapauta elävä maalaus Release Repeat Vapauta toisto Release Slice Vapauta siivu @@ -4987,6 +4989,7 @@ imageTrace Outline Ääriviiva @msg paths contain invalid coordinates polut sisältävät virheellisiä koordinaatteja @msg paths: give at least one file paths: anna vähintään yksi tiedosto @msg replace: the object to replace was changed or deleted replace: korvattavaa objektia on muutettu tai se on poistettu +@msg the document it was prepared for is no longer active asiakirja, jota varten se valmisteltiin, ei ole enää aktiivinen @msg pattern missing kuvio puuttuu @msg patterns nested too deeply are filled with mid-grey liian syvälle sisäkkäiset kuviot täytetään keskiharmaalla @msg percent must be above 0 and at most 6400, not {p} percent-arvon on oltava yli 0 ja enintään 6400, ei {p} @@ -5340,6 +5343,7 @@ imageTrace Outline Ääriviiva @msg every numbered name for it is taken in the Fonts folder kaikki sen numeroidut nimet ovat jo käytössä Fonts-kansiossa @msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} @msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} @@ -5607,6 +5611,8 @@ imageTrace Outline Ääriviiva Vivid Eloisa Spectrum Spektri Perceptual Havainnollinen + Hue Clockwise Sävy myötäpäivään + Hue Counterclockwise Sävy vastapäivään Relative Colorimetric Suhteellinen kolorimetrinen Absolute Colorimetric Absoluuttinen kolorimetrinen Exact Tarkka @@ -5845,7 +5851,9 @@ mark-style Japanese Japanilainen @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-kohteen face-arvon on oltava front tai back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-kohteen {key}-arvon on oltava luku, joka on vähintään {lo} ja enintään {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` on oltava taulukko -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiikat sijoitetaan tasaisille pinnoille: käytä objektityyppiä Pursota tai Taso +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafiikat sijoitetaan objektityypeille Taso, Pursota ja Pyöräytä +@msg Revolve maps graphics onto its side: map them onto the front Pyöräytä sijoittaa grafiikat kylkeensä: sijoita ne etupuolelle +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafiikat ovat liian monimutkaisia tälle pinnalle: yksinkertaista symbolien kuvitusta tai pienennä Segmentit-arvoa @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` hyväksyy enintään {MAX_GRAPHICS} grafiikkaa @msg A plane has one face: map its graphics onto the front Tasolla on yksi puoli: sijoita sen grafiikat etupuolelle @msg The graphics are too complex: simplify the symbols' art Grafiikat ovat liian monimutkaisia: yksinkertaista symbolien kuvitusta @@ -6053,6 +6061,7 @@ mark-style Japanese Japanilainen @msg no bound object reports a value (locked objects and embedded images report none) mikään sidottu objekti ei ilmoita arvoa (lukitut objektit ja upotetut kuvat eivät ilmoita arvoa) @msg select an Intertwine group valitse lomitusryhmä @msg select an Objects on Path group valitse Objektit polulla -ryhmä +@msg choose the object to detach with its Select/Move widget valitse irrotettava objekti sen Valitse/Siirrä-säätimellä @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects yhdistelmäpolun, sulautuksen, vääristyskuoren, toiston tai lomitusryhmän sisällä olevaan polkuun ei voi kiinnittää objekteja @msg a clipping path can't take objects leikkauspolkuun ei voi kiinnittää objekteja @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path sulautuksen, vääristyskuoren, toiston tai lomitusryhmän sisällä olevia objekteja ei voi kiinnittää polulle @@ -6339,7 +6348,7 @@ axis Y Axis Y-akseli @msg `dither` applies to gradient fills (stroke: false) `dither` koskee liukuväritäyttöjä (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill) @msg `dither` must be true or false `dither` on oltava true tai false -@msg `method` must be classic or perceptual `method` on oltava classic tai perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` on oltava classic, perceptual, hue-clockwise tai hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` on oltava pad, repeat tai reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` on oltava kokonaisluku väliltä 1–{MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` koskee lineaarisia, säteittäisiä ja kartiomaisia liukuvärejä sekä vinoneliöliukuvärejä @@ -6582,6 +6591,7 @@ axis Y Axis Y-akseli Continue Jatka Continue Action Jatka toimintoa Delete Action Step Poista toiminnon vaihe + Move Action Step Siirrä toiminnon vaihe Press Play Current Selection in the Actions panel to go on. Jatka valitsemalla Toiminnot-paneelista Toista nykyinen valinta. Stop: {message} Pysäytys: {message} @msg no action is waiting at a stop mikään toiminto ei odota pysäytyksessä @@ -6753,6 +6763,22 @@ axis Y Axis Y-akseli Graphite Pencil Grafiittikynä Wax Crayon Vahaliitu Charcoal Dust Hiilipöly + Notched Ribbon Lovettu nauha + Double Line Kaksoisviiva + Wave Aalto + Teardrop Pisara + Striped Ribbon Raidallinen nauha + Fine Nib Ohut kärki + Broad Nib Leveä kärki + Chisel Taltta + Round Pen Pyöreä kynä + Pressure Pen Painekynä + Beads Helmet + Dash Dot Viiva-piste + Scallops Kaarireunus + Stars Tähdet + Triangles Kolmiot + Sparkles Kipinät Save Brush Library… Tallenna sivellinkirjasto… @msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä @msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries) @@ -6772,6 +6798,13 @@ axis Y Axis Y-akseli Area Text Alueteksti # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mittaa teksti merkin rajoista +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Pikseliin kohdistuksen asetukset + Pixel Snapping Options… Pikseliin kohdistuksen asetukset… + Snap to Pixel While Drawing Kohdista pikseliin piirrettäessä + Snap to Pixel While Moving Kohdista pikseliin siirrettäessä + Snap to Pixel While Scaling Kohdista pikseliin skaalattaessa + These options take effect when View › Snap to Pixel is on. Nämä asetukset ovat voimassa, kun Näytä › Kohdista pikseliin on käytössä. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Näytä merkkiin kohdistuksen asetukset Hide Snap to Glyph Options Piilota merkkiin kohdistuksen asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 180079848f..dc34922b78 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1586,6 +1586,8 @@ font-technology Variable Variables Objects on Path Tool Outil Objets sur tracé Attach Objects on Path Attacher des objets au tracé Expand Objects on Path Décomposer les objets sur tracé + Detach Détacher + Detach Object from Path Détacher l’objet du tracé Release Live Paint Annuler la peinture dynamique Release Repeat Annuler la répétition Release Slice Annuler la tranche @@ -4762,6 +4764,7 @@ axis Both Les deux @msg paths contain invalid coordinates les tracés contiennent des coordonnées non valides @msg paths: give at least one file paths : indiquez au moins un fichier @msg replace: the object to replace was changed or deleted replace : l’objet à remplacer a été modifié ou supprimé +@msg the document it was prepared for is no longer active le document pour lequel il a été préparé n’est plus actif @msg pattern missing motif manquant @msg patterns nested too deeply are filled with mid-grey les motifs imbriqués trop profondément sont remplis de gris moyen @msg percent must be above 0 and at most 6400, not {p} percent doit être supérieur à 0 et au plus égal à 6400, pas {p} @@ -4797,6 +4800,7 @@ axis Both Les deux @msg select a repeat sélectionnez une répétition @msg select an Intertwine group sélectionnez un groupe entrelacé @msg select an Objects on Path group sélectionnez un groupe d’objets sur tracé +@msg choose the object to detach with its Select/Move widget choisissez l’objet à détacher avec sa commande Sélectionner/Déplacer @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un tracé à l’intérieur d’un tracé transparent, d’un dégradé de formes, d’une enveloppe, d’une répétition ou d’un groupe entrelacé ne peut pas recevoir d’objets @msg a clipping path can't take objects un tracé d’écrêtage ne peut pas recevoir d’objets @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path les objets à l’intérieur d’un dégradé de formes, d’une enveloppe, d’une répétition ou d’un groupe entrelacé ne peuvent pas être attachés à un tracé @@ -5119,6 +5123,7 @@ axis Both Les deux @msg every numbered name for it is taken in the Fonts folder tous les noms numérotés pour ce fichier sont déjà pris dans le dossier Fonts @msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document @msg No linked files to look for Aucun fichier lié à rechercher +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services @@ -5533,7 +5538,9 @@ lighting Light Lumière @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` doit valoir front ou back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` doit être un nombre de {lo} à {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` doit être un tableau -@msg Graphics map onto planar faces: use the Extrude or Plane object type Les graphiques s'appliquent sur des faces planes : utilisez le type d'objet Extrusion ou Plan +@msg Graphics map onto the Plane, Extrude and Revolve object types Les graphiques s'appliquent aux types d'objet Plan, Extrusion et Révolution +@msg Revolve maps graphics onto its side: map them onto the front Révolution applique les graphiques sur sa face latérale : appliquez-les sur la face avant +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Les graphiques sont trop complexes pour cette surface : simplifiez l'illustration des symboles ou réduisez Segments @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accepte au plus {MAX_GRAPHICS} graphiques @msg A plane has one face: map its graphics onto the front Un plan n'a qu'une face : appliquez ses graphiques sur la face avant @msg The graphics are too complex: simplify the symbols' art Les graphiques sont trop complexes : simplifiez l'illustration des symboles @@ -5775,6 +5782,8 @@ lighting Light Lumière Pen Plume Pentagram Pentagramme Perceptual Perceptif + Hue Clockwise Teinte en sens horaire + Hue Counterclockwise Teinte en sens antihoraire Phone 360×800 Téléphone 360×800 Phone 375×667 Téléphone 375×667 Phone 375×812 Téléphone 375×812 @@ -6304,7 +6313,7 @@ axis Y Axis Axe Y @msg `dither` applies to gradient fills (stroke: false) `dither` s’applique aux fonds en dégradé (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill) @msg `dither` must be true or false `dither` doit valoir true ou false -@msg `method` must be classic or perceptual `method` doit être classic ou perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` doit être classic, perceptual, hue-clockwise ou hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` doit être pad, repeat ou reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` doit être un nombre entier de 1 à {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’applique aux dégradés linéaires, radiaux, coniques et en losange @@ -6547,6 +6556,7 @@ axis Y Axis Axe Y Continue Continuer Continue Action Continuer le script Delete Action Step Supprimer l’étape du script + Move Action Step Déplacer l’étape du script Press Play Current Selection in the Actions panel to go on. Pour continuer, cliquez sur Exécuter la sélection actuelle dans le panneau Scripts. Stop: {message} Arrêt : {message} @msg no action is waiting at a stop aucun script n’attend à un arrêt @@ -6718,6 +6728,22 @@ axis Y Axis Axe Y Graphite Pencil Crayon graphite Wax Crayon Craie grasse Charcoal Dust Poussière de fusain + Notched Ribbon Ruban cranté + Double Line Double ligne + Wave Vague + Teardrop Goutte + Striped Ribbon Ruban rayé + Fine Nib Plume fine + Broad Nib Plume large + Chisel Ciseau + Round Pen Plume ronde + Pressure Pen Plume à pression + Beads Perles + Dash Dot Tiret-point + Scallops Festons + Stars Étoiles + Triangles Triangles + Sparkles Étincelles Save Brush Library… Enregistrer la bibliothèque de formes… @msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer @msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries) @@ -6737,6 +6763,13 @@ axis Y Axis Axe Y Area Text Texte captif # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mesurer le texte d’après les limites du glyphe +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Options de magnétisme du pixel + Pixel Snapping Options… Options de magnétisme du pixel… + Snap to Pixel While Drawing Magnétisme du pixel lors du dessin + Snap to Pixel While Moving Magnétisme du pixel lors du déplacement + Snap to Pixel While Scaling Magnétisme du pixel lors de la mise à l’échelle + These options take effect when View › Snap to Pixel is on. Ces options s’appliquent lorsque Affichage › Magnétisme du pixel est activé. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Afficher les options de magnétisme du glyphe Hide Snap to Glyph Options Masquer les options de magnétisme du glyphe diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index ae447126a6..d900c302e8 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -444,6 +444,22 @@ antiAlias Sharp शार्प Graphite Pencil ग्रेफाइट पेंसिल Wax Crayon मोम क्रेयॉन Charcoal Dust चारकोल धूल + Notched Ribbon नोकदार रिबन + Double Line दोहरी लाइन + Wave लहर + Teardrop आंसू की बूंद + Striped Ribbon धारीदार रिबन + Fine Nib बारीक निब + Broad Nib चौड़ी निब + Chisel छेनी + Round Pen गोल पेन + Pressure Pen प्रेशर पेन + Beads मोती + Dash Dot डैश डॉट + Scallops स्कैलप + Stars तारे + Triangles त्रिभुज + Sparkles चमक Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} @@ -1710,6 +1726,8 @@ antiAlias Sharp शार्प Expand Live Paint लाइव पेंट एक्सपैंड करें Expand Object ऑब्जेक्ट का विस्तार Expand Objects on Path पाथ पर ऑब्जेक्ट्स एक्सपैंड करें + Detach अलग करें + Detach Object from Path पाथ से ऑब्जेक्ट अलग करें Expand Options ऑप्शन्स एक्सपैंड करें Expand Panels पैनलों का विस्तार Expand Repeat रिपीट एक्सपैंड करें @@ -3410,6 +3428,8 @@ antiAlias Sharp शार्प per slice प्रति स्लाइस Percent: प्रतिशत: Perceptual परसेप्चुअल + Hue Clockwise ह्यू घड़ी की दिशा में + Hue Counterclockwise ह्यू घड़ी की विपरीत दिशा में Perceptual Scales अवधारणात्मक स्केल Performance प्रदर्शन Permissions अनुमतियां @@ -3478,6 +3498,7 @@ antiAlias Sharp शार्प Continue जारी रखें Continue Action एक्शन जारी रखें Delete Action Step एक्शन का चरण हटाएं + Move Action Step एक्शन का चरण खिसकाएं Press Play Current Selection in the Actions panel to go on. आगे बढ़ने के लिए एक्शन्स पैनल में वर्तमान सिलेक्शन प्ले करें दबाएँ। Stop: {message} स्टॉप: {message} @msg no action is waiting at a stop कोई एक्शन स्टॉप पर प्रतीक्षा नहीं कर रहा है @@ -4571,6 +4592,12 @@ antiAlias Sharp शार्प Show Slice Numbers स्लाइस नंबर दिखाएँ Show Slices स्लाइस दिखाएँ Show smooth color wheel चिकनी रंग पहिया दिखाएँ + Pixel Snapping Options पिक्सेल पर स्नैप ऑप्शन्स + Pixel Snapping Options… पिक्सेल पर स्नैप ऑप्शन्स… + Snap to Pixel While Drawing ड्रॉ करते समय पिक्सेल के लिए स्नैप + Snap to Pixel While Moving मूव करते समय पिक्सेल के लिए स्नैप + Snap to Pixel While Scaling स्केल करते समय पिक्सेल के लिए स्नैप + These options take effect when View › Snap to Pixel is on. ये ऑप्शन्स तब लागू होते हैं जब व्यू › पिक्सेल के लिए स्नैप चालू हो। Show Snap to Glyph Options ग्लिफ़ पर स्नैप ऑप्शन्स दिखाएँ Show Swatch Bounds स्वॉच की सीमाएँ दिखाएँ Show Swatch Kinds स्वॉच प्रकार दिखाएँ @@ -6074,7 +6101,7 @@ antiAlias Sharp शार्प @msg `length` must be a number of at most 1000000 pt either way `length` को 1000000 pt में से एक होना चाहिए। @msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें) @msg `line` must be true or false `line` को true या false होना चाहिए -@msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` classic, perceptual, hue-clockwise या hue-counterclockwise होना चाहिए @msg `spread` must be pad, repeat or reflect `spread` pad, repeat या reflect होना चाहिए @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 से {MAX_REPEATS} तक की पूर्ण संख्या होनी चाहिए @msg `spread` applies to linear, radial, conic and diamond gradients `spread` रैखिक, रेडियल, शंक्वाकार और डायमंड ग्रेडिएंट्स पर लागू होता है @@ -7267,6 +7294,7 @@ antiAlias Sharp शार्प @msg no library `{key}` (see {list}) कोई लाइब्रेरी `{key}` ({list} देखें) @msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं @msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है @msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं @msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं @msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट @@ -7674,6 +7702,7 @@ antiAlias Sharp शार्प @msg Replace Spine: select a blend and a path Spine: एक मिश्रण और एक पाथ चुनें @msg replace: select the one object to replace प्रतिस्थापित करने के लिए एक ऑब्जेक्ट सिलेक्ट करें @msg replace: the object to replace was changed or deleted प्रतिस्थापित करने के लिए ऑब्जेक्ट को बदल दिया गया था या हटा दिया गया +@msg the document it was prepared for is no longer active जिस डॉक्यूमेंट के लिए इसे तैयार किया गया था, वह अब सक्रिय नहीं है @msg Reset {_1} रीसेट करें {_1} @msg resolution must be a number or screen|medium|high resolution: रिज़ॉल्यूशन एक नंबर या स्क्रीन होना चाहिए @msg resolution must be between 1 and 2400 ppi resolution: रिज़ॉल्यूशन 1 और 2400 ppi के बीच होना चाहिए @@ -7757,6 +7786,7 @@ antiAlias Sharp शार्प @msg select an object with a freeform gradient एक फ्रीफॉर्म ग्रेडिएंट के साथ एक ऑब्जेक्ट सिलेक्ट करें @msg select an object with an opacity mask एक opacity मास्क के साथ एक ऑब्जेक्ट सिलेक्ट करें @msg select an Objects on Path group पाथ समूह पर एक ऑब्जेक्ट चुनें +@msg choose the object to detach with its Select/Move widget अलग करने वाले ऑब्जेक्ट को उसके चुनें/ले जाएँ विजेट से चुनें @msg select area type एरिया टाइप सिलेक्ट करें @msg select area type (text in a frame) एरिया टाइप (फ़्रेम में टेक्स्ट) सिलेक्ट करें @msg select area type and closed paths (at least one area type object) एरिया टाइप और बंद पाथ सिलेक्ट करें (कम से कम एक एरिया टाइप ऑब्जेक्ट) @@ -7947,7 +7977,9 @@ antiAlias Sharp शार्प @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` का face, front या back होना चाहिए @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` का {key}, {lo} से {hi} तक की संख्या होना चाहिए @msg `{GRAPHICS}` must be an array `{GRAPHICS}` एक ऐरे होना चाहिए -@msg Graphics map onto planar faces: use the Extrude or Plane object type ग्राफ़िक्स समतल फ़ेस पर लगते हैं: एक्सट्रूड या समतल ऑब्जेक्ट प्रकार का उपयोग करें +@msg Graphics map onto the Plane, Extrude and Revolve object types ग्राफ़िक्स समतल, एक्सट्रूड और रिवॉल्व ऑब्जेक्ट प्रकारों पर लगते हैं +@msg Revolve maps graphics onto its side: map them onto the front रिवॉल्व ग्राफ़िक्स को अपनी बगल वाली सतह पर लगाता है: उन्हें सामने वाले फ़ेस पर लगाएँ +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments ग्राफ़िक्स इस सतह के लिए बहुत जटिल हैं: सिंबल्स के आर्ट को सरल बनाएँ या सेगमेंट कम करें @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` अधिकतम {MAX_GRAPHICS} ग्राफ़िक्स लेता है @msg A plane has one face: map its graphics onto the front समतल का एक ही फ़ेस होता है: उसके ग्राफ़िक्स को सामने वाले फ़ेस पर लगाएँ @msg The graphics are too complex: simplify the symbols' art ग्राफ़िक्स बहुत जटिल हैं: सिंबल्स के आर्ट को सरल बनाएँ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1bd67ee6c2..ce998e2459 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2003,6 +2003,8 @@ file-filter Templates Sablonok Objects on Path Tool Objektumok útvonalon Attach Objects on Path Objektumok csatolása útvonalhoz Expand Objects on Path Objektumok útvonalon kibontása + Detach Leválasztás + Detach Object from Path Objektum leválasztása az útvonalról Release Live Paint Élő festés megszüntetése Release Repeat Ismétlés megszüntetése Release Slice Szelet megszüntetése @@ -3030,6 +3032,8 @@ file-filter Templates Sablonok per slice szeletenként Percent: Százalék: Perceptual Észlelési + Hue Clockwise Színárnyalat az óramutató járásával megegyezően + Hue Counterclockwise Színárnyalat az óramutató járásával ellentétesen Perceptual Scales Észlelési skálák Performance Teljesítmény diagnostics render renderelés @@ -5176,6 +5180,7 @@ imageTrace Outline Körvonal @msg every numbered name for it is taken in the Fonts folder a Fonts mappában minden sorszámozott név foglalt hozzá @msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name} @msg No linked files to look for Nincs keresendő csatolt fájl +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon @msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát) @msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van @msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás @@ -5482,6 +5487,7 @@ imageTrace Outline Körvonal @msg paths contain invalid coordinates az útvonalakban érvénytelen koordináták vannak @msg paths: give at least one file paths: legalább egy fájlt adj meg @msg replace: the object to replace was changed or deleted replace: a cserélendő objektumot módosították vagy törölték +@msg the document it was prepared for is no longer active az a dokumentum, amelyhez előkészítették, már nem aktív @msg pattern missing hiányzik a mintázat @msg patterns nested too deeply are filled with mid-grey a túl mélyen egymásba ágyazott mintázatok helyén középszürke kitöltés áll @msg PDF settings are not an object a PDF-beállítások nem objektumot alkotnak @@ -5564,6 +5570,7 @@ imageTrace Outline Körvonal @msg select a repeat jelölj ki egy ismétlést @msg select an Intertwine group jelölj ki egy összefonási csoportot @msg select an Objects on Path group jelölj ki egy Objektumok útvonalon csoportot +@msg choose the object to detach with its Select/Move widget válaszd ki a leválasztandó objektumot a Kijelölés/Mozgatás vezérlőjével @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects összetett útvonalban, összemosásban, burkolóban, ismétlésben vagy összefonási csoportban lévő útvonalhoz nem csatolhatók objektumok @msg a clipping path can't take objects vágóútvonalhoz nem csatolhatók objektumok @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path összemosásban, burkolóban, ismétlésben vagy összefonási csoportban lévő objektumok nem csatolhatók útvonalhoz @@ -5946,7 +5953,9 @@ imageTrace Outline Körvonal @msg `{GRAPHICS}` face must be front or back a `{GRAPHICS}` face értéke front vagy back lehet @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} a `{GRAPHICS}` {key} értéke {lo} és {hi} közötti szám lehet @msg `{GRAPHICS}` must be an array a `{GRAPHICS}` értéke tömb kell legyen -@msg Graphics map onto planar faces: use the Extrude or Plane object type A grafikák sík lapokra kerülnek: használja a Kihúzás vagy a Sík objektumtípust +@msg Graphics map onto the Plane, Extrude and Revolve object types A grafikák a Sík, a Kihúzás és a Forgatás tengely körül objektumtípusra kerülnek +@msg Revolve maps graphics onto its side: map them onto the front A Forgatás tengely körül a grafikákat a palástjára helyezi: az elülső oldalra helyezze őket +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments A grafikák túl összetettek ehhez a felülethez: egyszerűsítse a szimbólumok rajzát, vagy csökkentse a Szakaszok értékét @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics a `{GRAPHICS}` legfeljebb {MAX_GRAPHICS} grafikát fogad @msg A plane has one face: map its graphics onto the front A síknak egy oldala van: a grafikáit az elülső oldalra helyezze @msg The graphics are too complex: simplify the symbols' art A grafikák túl összetettek: egyszerűsítse a szimbólumok rajzát @@ -6292,7 +6301,7 @@ axis Y Axis Y tengely @msg `dither` applies to gradient fills (stroke: false) A `dither` színátmenetes kitöltésekre vonatkozik (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill) @msg `dither` must be true or false `dither`: true vagy false kell -@msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise a `method` értéke classic, perceptual, hue-clockwise vagy hue-counterclockwise lehet @msg `spread` must be pad, repeat or reflect a `spread` értéke pad, repeat vagy reflect lehet @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} a `repeats` értéke 1 és {MAX_REPEATS} közötti egész szám lehet @msg `spread` applies to linear, radial, conic and diamond gradients a `spread` a lineáris, sugaras, kúpos és rombusz színátmenetekre vonatkozik @@ -6535,6 +6544,7 @@ axis Y Axis Y tengely Continue Folytatás Continue Action Művelet folytatása Delete Action Step Műveletlépés törlése + Move Action Step Műveletlépés áthelyezése Press Play Current Selection in the Actions panel to go on. A folytatáshoz nyomja meg a Műveletek panelen a Kijelölt művelet lejátszása gombot. Stop: {message} Megállás: {message} @msg no action is waiting at a stop egyetlen művelet sem várakozik megállásnál @@ -6706,6 +6716,22 @@ axis Y Axis Y tengely Graphite Pencil Grafitceruza Wax Crayon Zsírkréta Charcoal Dust Szénpor + Notched Ribbon Csipkézett szalag + Double Line Dupla vonal + Wave Hullám + Teardrop Csepp + Striped Ribbon Csíkos szalag + Fine Nib Finom tollhegy + Broad Nib Széles tollhegy + Chisel Véső + Round Pen Kerek toll + Pressure Pen Nyomásérzékeny toll + Beads Gyöngyök + Dash Dot Pont-vonal + Scallops Hullámos szegély + Stars Csillagok + Triangles Háromszögek + Sparkles Szikrák Save Brush Library… Ecsetkönyvtár mentése… @msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet @msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries) @@ -6725,6 +6751,13 @@ axis Y Axis Y tengely Area Text Területszöveg # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Szöveg mérése a glifahatárok szerint +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Pixelhez illesztés beállításai + Pixel Snapping Options… Pixelhez illesztés beállításai… + Snap to Pixel While Drawing Illesztés pixelhez rajzoláskor + Snap to Pixel While Moving Illesztés pixelhez mozgatáskor + Snap to Pixel While Scaling Illesztés pixelhez méretezéskor + These options take effect when View › Snap to Pixel is on. Ezek a beállítások akkor érvényesek, ha a Nézet › Illesztés pixelhez be van kapcsolva. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glifához illesztés beállításainak megjelenítése Hide Snap to Glyph Options Glifához illesztés beállításainak elrejtése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3e2d38737c..d17e1a5b07 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4619,6 +4619,7 @@ imageTrace Outline Kontur @msg paths contain invalid coordinates jalur berisi koordinat yang tidak valid @msg paths: give at least one file jalur: berikan setidaknya satu berkas @msg replace: the object to replace was changed or deleted ganti: objek yang akan diganti telah diubah atau dihapus +@msg the document it was prepared for is no longer active dokumen yang menjadi tujuan persiapannya tidak lagi aktif @msg pattern missing pola hilang @msg patterns nested too deeply are filled with mid-grey pola yang bersarang terlalu dalam diisi dengan abu-abu tengah @msg percent must be above 0 and at most 6400, not {p} percent harus di atas 0 dan paling banyak 6400, bukan {p} @@ -5181,6 +5182,8 @@ imageTrace Outline Kontur Vivid Cerah Spectrum Spektrum Perceptual Perseptual + Hue Clockwise Rona searah jarum jam + Hue Counterclockwise Rona berlawanan arah jarum jam Relative Colorimetric Kolorimetrik relatif Absolute Colorimetric Kolorimetrik absolut Exact Eksak @@ -5846,6 +5849,8 @@ swatch Bright Violet Violet cerah Objects on Path Tool Alat objek pada jalur Attach Objects on Path Tempelkan objek pada jalur Expand Objects on Path Perluas objek pada jalur + Detach Lepaskan + Detach Object from Path Lepaskan objek dari jalur Release Live Paint Lepaskan Live Paint Release Repeat Lepaskan perulangan Release Slice Lepaskan slice @@ -5993,7 +5998,7 @@ axis Y Axis Sumbu Y @msg `dither` applies to gradient fills (stroke: false) `dither` berlaku untuk isian gradien (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` berlaku untuk isian gradien (paint.setFill) @msg `dither` must be true or false `dither` harus true atau false -@msg `method` must be classic or perceptual `method` harus classic atau perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` harus classic, perceptual, hue-clockwise atau hue-counterclockwise # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Instal “{name}”, yang meminta anggaran komputasi besar? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Setiap kali dijalankan, plug-in ini bisa memakan waktu hingga {minutes} menit dan melakukan {times} kali pekerjaan yang diizinkan untuk plug-in lain. Plug-in ini berjalan di latar belakang, dan Anda dapat membatalkannya. @@ -6034,6 +6039,7 @@ axis Y Axis Sumbu Y Continue Lanjutkan Continue Action Lanjutkan tindakan Delete Action Step Hapus langkah tindakan + Move Action Step Pindahkan langkah tindakan Press Play Current Selection in the Actions panel to go on. Untuk melanjutkan, tekan Putar seleksi saat ini di panel Tindakan. Stop: {message} Henti: {message} @msg no action is waiting at a stop tidak ada tindakan yang menunggu di titik henti @@ -6194,6 +6200,22 @@ axis Y Axis Sumbu Y Graphite Pencil Pensil grafit Wax Crayon Krayon lilin Charcoal Dust Debu arang + Notched Ribbon Pita bertakik + Double Line Garis ganda + Wave Gelombang + Teardrop Tetesan + Striped Ribbon Pita bergaris + Fine Nib Mata pena halus + Broad Nib Mata pena lebar + Chisel Pahat + Round Pen Pena bulat + Pressure Pen Pena tekanan + Beads Manik-manik + Dash Dot Garis titik + Scallops Lekuk kerang + Stars Bintang + Triangles Segitiga + Sparkles Kilauan Save Brush Library… Simpan pustaka kuas… @msg no empty artboards to delete tidak ada artboard kosong untuk dihapus Scale Presets Preset skala @@ -6205,6 +6227,13 @@ axis Y Axis Sumbu Y Area Text Teks area # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Ukur teks menurut batas glif +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opsi snap ke piksel + Pixel Snapping Options… Opsi snap ke piksel… + Snap to Pixel While Drawing Snap ke piksel saat menggambar + Snap to Pixel While Moving Snap ke piksel saat memindahkan + Snap to Pixel While Scaling Snap ke piksel saat menskalakan + These options take effect when View › Snap to Pixel is on. Opsi ini berlaku saat Tampilan › Snap ke piksel aktif. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Tampilkan opsi snap ke glif Hide Snap to Glyph Options Sembunyikan opsi snap ke glif diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8c760f1e2a..aed9283967 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1623,6 +1623,8 @@ file-filter Templates Modelli Objects on Path Tool Strumento Oggetti su tracciato Attach Objects on Path Aggancia oggetti al tracciato Expand Objects on Path Espandi oggetti su tracciato + Detach Sgancia + Detach Object from Path Sgancia oggetto dal tracciato Release Live Paint Rilascia pittura dinamica Release Repeat Rilascia ripetizione Release Slice Rilascia sezione @@ -4825,6 +4827,7 @@ axis Both Entrambi @msg paths contain invalid coordinates i tracciati contengono coordinate non valide @msg paths: give at least one file paths: specifica almeno un file @msg replace: the object to replace was changed or deleted replace: l’oggetto da sostituire è stato modificato o eliminato +@msg the document it was prepared for is no longer active il documento per cui è stato preparato non è più attivo @msg pattern missing motivo mancante @msg patterns nested too deeply are filled with mid-grey i motivi annidati troppo in profondità vengono riempiti di grigio medio @msg percent must be above 0 and at most 6400, not {p} percent deve essere maggiore di 0 e al massimo 6400, non {p} @@ -4860,6 +4863,7 @@ axis Both Entrambi @msg select a repeat seleziona una ripetizione @msg select an Intertwine group seleziona un gruppo intrecciato @msg select an Objects on Path group seleziona un gruppo di oggetti su tracciato +@msg choose the object to detach with its Select/Move widget scegli l’oggetto da sganciare con il suo controllo Seleziona/Sposta @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un tracciato all'interno di un tracciato composto, una fusione, un involucro, una ripetizione o un gruppo intrecciato non può ricevere oggetti @msg a clipping path can't take objects un tracciato di ritaglio non può ricevere oggetti @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path gli oggetti all'interno di una fusione, un involucro, una ripetizione o un gruppo intrecciato non possono essere agganciati a un tracciato @@ -5184,6 +5188,7 @@ axis Both Entrambi @msg every numbered name for it is taken in the Fonts folder tutti i nomi numerati per il file sono già usati nella cartella Fonts @msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento @msg No linked files to look for Nessun file collegato da cercare +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi @@ -5581,7 +5586,9 @@ lighting Light Luce @msg `{GRAPHICS}` face must be front or back face di `{GRAPHICS}` deve essere front o back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} di `{GRAPHICS}` deve essere un numero da {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve essere un array -@msg Graphics map onto planar faces: use the Extrude or Plane object type Le grafiche si applicano a facce piane: usate il tipo di oggetto Estrusione o Piano +@msg Graphics map onto the Plane, Extrude and Revolve object types Le grafiche si applicano ai tipi di oggetto Piano, Estrusione e Rivoluzione +@msg Revolve maps graphics onto its side: map them onto the front Rivoluzione applica le grafiche sul suo lato: applicatele sulla faccia anteriore +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Le grafiche sono troppo complesse per questa superficie: semplificate il disegno dei simboli o riducete Segmenti @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accetta al massimo {MAX_GRAPHICS} grafiche @msg A plane has one face: map its graphics onto the front Un piano ha una sola faccia: applicate le sue grafiche sulla faccia anteriore @msg The graphics are too complex: simplify the symbols' art Le grafiche sono troppo complesse: semplificate il disegno dei simboli @@ -5935,7 +5942,9 @@ axis Y Axis Asse Y @msg `dither` applies to gradient fills (paint.setFill) `dither` si applica ai riempimenti sfumati (paint.setFill) @msg `dither` must be true or false `dither` deve essere true o false Perceptual Percettivo -@msg `method` must be classic or perceptual `method` deve essere classic o perceptual + Hue Clockwise Tonalità in senso orario + Hue Counterclockwise Tonalità in senso antiorario +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` deve essere classic, perceptual, hue-clockwise o hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` deve essere pad, repeat o reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve essere un numero intero da 1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` si applica alle sfumature lineari, radiali, coniche e a rombo @@ -6178,6 +6187,7 @@ axis Y Axis Asse Y Continue Continua Continue Action Continua azione Delete Action Step Elimina passaggio dell’azione + Move Action Step Sposta passaggio dell’azione Press Play Current Selection in the Actions panel to go on. Per continuare, premi Esegui selezione corrente nel pannello Azioni. Stop: {message} Interruzione: {message} @msg no action is waiting at a stop nessuna azione è in attesa a un'interruzione @@ -6349,6 +6359,22 @@ axis Y Axis Asse Y Graphite Pencil Matita di grafite Wax Crayon Pastello a cera Charcoal Dust Polvere di carboncino + Notched Ribbon Nastro dentellato + Double Line Doppia linea + Wave Onda + Teardrop Goccia + Striped Ribbon Nastro a righe + Fine Nib Pennino fine + Broad Nib Pennino largo + Chisel Scalpello + Round Pen Penna tonda + Pressure Pen Penna a pressione + Beads Perline + Dash Dot Tratto-punto + Scallops Smerlo + Stars Stelle + Triangles Triangoli + Sparkles Scintille Save Brush Library… Salva libreria pennelli… @msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare @msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries) @@ -6368,6 +6394,13 @@ axis Y Axis Asse Y Area Text Testo in area # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Misura il testo in base ai limiti del glifo +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opzioni di aggancio ai pixel + Pixel Snapping Options… Opzioni di aggancio ai pixel… + Snap to Pixel While Drawing Aggancia ai pixel durante il disegno + Snap to Pixel While Moving Aggancia ai pixel durante lo spostamento + Snap to Pixel While Scaling Aggancia ai pixel durante il ridimensionamento + These options take effect when View › Snap to Pixel is on. Queste opzioni hanno effetto quando Visualizza › Aggancia ai pixel è attivo. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra opzioni di aggancio a glifo Hide Snap to Glyph Options Nascondi opzioni di aggancio a glifo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d33a3be88a..10a2a32ac5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1992,6 +1992,8 @@ antiAlias Sharp シャープ Objects on Path Tool パス上のオブジェクトツール Attach Objects on Path パス上のオブジェクトをアタッチ Expand Objects on Path パス上のオブジェクトを拡張 + Detach 切り離し + Detach Object from Path オブジェクトをパスから切り離し Release Live Paint ライブペイントを解除 Release Repeat リピートを解除 Release Slice スライスを解除 @@ -4582,6 +4584,7 @@ swatch Bright Violet ビビッドバイオレット @msg select a repeat リピートを選択してください @msg select an Intertwine group 織り交ぜグループを選択してください @msg select an Objects on Path group パス上のオブジェクトグループを選択してください +@msg choose the object to detach with its Select/Move widget 切り離すオブジェクトを選択/移動ウィジェットで選んでください @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 複合パス、ブレンド、エンベロープ、リピートまたは織り交ぜグループ内のパスにはオブジェクトをアタッチできません @msg a clipping path can't take objects クリッピングパスにはオブジェクトをアタッチできません @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path ブレンド、エンベロープ、リピートまたは織り交ぜグループ内のオブジェクトはパスにアタッチできません @@ -5188,6 +5191,7 @@ swatch Bright Violet ビビッドバイオレット @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` は VectorCraft ドキュメント、PDF、PDF 互換の .ai ファイルのいずれでもありません @msg paths: give at least one file paths: ファイルを 1 つ以上指定してください @msg replace: the object to replace was changed or deleted replace: 置き換えるオブジェクトは変更または削除されています +@msg the document it was prepared for is no longer active 準備したときのドキュメントがもうアクティブではありません @msg Couldn't place {name}: {e} {name} を配置できませんでした: {e} @msg no print service プリントサービスがありません @msg the print job has no data プリントジョブにデータがありません @@ -5251,6 +5255,7 @@ swatch Bright Violet ビビッドバイオレット @msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています @msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています @msg No linked files to look for 検索するリンクファイルがありません +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) @@ -5623,7 +5628,10 @@ lighting Light ライト @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` の face には front または back を指定してください @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` の {key} は {lo} から {hi} までの数値にしてください @msg `{GRAPHICS}` must be an array `{GRAPHICS}` は配列にしてください -@msg Graphics map onto planar faces: use the Extrude or Plane object type グラフィックは平らな面にマッピングされます。オブジェクトの種類に押し出しまたは平面を使用してください +@msg Graphics map onto the Plane, Extrude and Revolve object types グラフィックはオブジェクトの種類が平面、押し出し、回転体のときにマッピングされます + Graphics map onto the Plane, Extrude and Revolve object types. グラフィックはオブジェクトの種類が平面、押し出し、回転体のときにマッピングされます。 +@msg Revolve maps graphics onto its side: map them onto the front 回転体はグラフィックを側面にマッピングします。前面にマッピングしてください +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments グラフィックがこのサーフェスには複雑すぎます。シンボルのアートを単純化するか、分割数を減らしてください @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` に指定できるグラフィックは {MAX_GRAPHICS} 個までです @msg A plane has one face: map its graphics onto the front 平面には面が 1 つしかありません。グラフィックは前面にマッピングしてください @msg The graphics are too complex: simplify the symbols' art グラフィックが複雑すぎます。シンボルのアートを単純化してください @@ -6000,7 +6008,9 @@ axis Y Axis Y 軸 @msg `dither` applies to gradient fills (paint.setFill) `dither` はグラデーションの塗りに適用されます (paint.setFill) @msg `dither` must be true or false `dither` には true または false を指定してください Perceptual 知覚的 -@msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください + Hue Clockwise 時計回りの色相 + Hue Counterclockwise 反時計回りの色相 +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` には classic、perceptual、hue-clockwise または hue-counterclockwise を指定してください @msg `spread` must be pad, repeat or reflect `spread` には pad、repeat または reflect を指定してください @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` には 1 から {MAX_REPEATS} までの整数を指定してください @msg `spread` applies to linear, radial, conic and diamond gradients `spread` は線形、円形、円錐およびひし形グラデーションに適用されます @@ -6243,6 +6253,7 @@ axis Y Axis Y 軸 Continue 続行 Continue Action アクションを続行 Delete Action Step アクションステップを削除 + Move Action Step アクションステップを移動 Press Play Current Selection in the Actions panel to go on. 続行するには、アクションパネルの「選択項目を再生」を押します。 Stop: {message} 停止: {message} @msg no action is waiting at a stop 停止で待機中のアクションはありません @@ -6414,6 +6425,22 @@ axis Y Axis Y 軸 Graphite Pencil 黒鉛鉛筆 Wax Crayon ワックスクレヨン Charcoal Dust 木炭の粉 + Notched Ribbon ノッチリボン + Double Line 二重線 + Wave 波 + Teardrop しずく + Striped Ribbon ストライプリボン + Fine Nib 細いペン先 + Broad Nib 太いペン先 + Chisel 平ノミ + Round Pen 丸ペン + Pressure Pen 筆圧ペン + Beads ビーズ + Dash Dot 一点鎖線 + Scallops スカラップ + Stars 星 + Triangles 三角形 + Sparkles きらめき Save Brush Library… ブラシライブラリを保存… @msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません @msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照) @@ -6433,6 +6460,13 @@ axis Y Axis Y 軸 Area Text エリア内文字 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 文字を字形の境界で測定 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options ピクセルにスナップのオプション + Pixel Snapping Options… ピクセルにスナップのオプション… + Snap to Pixel While Drawing 描画時にピクセルにスナップ + Snap to Pixel While Moving 移動時にピクセルにスナップ + Snap to Pixel While Scaling 拡大・縮小時にピクセルにスナップ + These options take effect when View › Snap to Pixel is on. これらのオプションは、表示 › ピクセルにスナップがオンのときに有効になります。 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options グリフにスナップのオプションを表示 Hide Snap to Glyph Options グリフにスナップのオプションを隠す diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f014533f1e..140afe47d1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4448,6 +4448,7 @@ font-technology Variable ცვლადი @msg paths contain invalid coordinates ტრაექტორიებში არასწორი კოორდინატებია @msg paths: give at least one file paths: მიუთითეთ ერთი ფაილი მაინც @msg replace: the object to replace was changed or deleted replace: ჩასანაცვლებელი ობიექტი შეიცვალა ან წაიშალა +@msg the document it was prepared for is no longer active დოკუმენტი, რომლისთვისაც მომზადდა, აღარ არის აქტიური @msg pattern missing პატერნი აკლია @msg patterns nested too deeply are filled with mid-grey ზედმეტად ღრმად ჩადგმული პატერნები საშუალო ნაცრისფრით ივსება @msg percent must be above 0 and at most 6400, not {p} percent უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p} @@ -5029,6 +5030,8 @@ font-technology Variable ცვლადი High Contrast 3 მაღალი კონტრასტი 3 Pentagram პენტაგრამა Perceptual აღქმითი + Hue Clockwise ფერის ტონი საათის ისრის მიმართულებით + Hue Counterclockwise ფერის ტონი საათის ისრის საწინააღმდეგოდ Relative Colorimetric ფარდობითი კოლორიმეტრიული Absolute Colorimetric აბსოლუტური კოლორიმეტრიული Exact ზუსტი @@ -5150,6 +5153,7 @@ font-technology Variable ცვლადი @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same მათზე არსებული ნახატი გვერდისგან {_1}%-ით განსხვავდება: მიუხედავად ამისა, ისინი რედაქტირების მონაცემებიდან აიღება @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული @@ -5251,7 +5255,9 @@ font-technology Variable ცვლადი @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-ის face უნდა იყოს front ან back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-ის {key} უნდა იყოს რიცხვი {lo}-დან {hi}-მდე @msg `{GRAPHICS}` must be an array `{GRAPHICS}` უნდა იყოს მასივი -@msg Graphics map onto planar faces: use the Extrude or Plane object type გრაფიკა ბრტყელ წახნაგებზე თავსდება: გამოიყენეთ ობიექტის ტიპი „ამოწევა“ ან „სიბრტყე“ +@msg Graphics map onto the Plane, Extrude and Revolve object types გრაფიკა თავსდება ობიექტის ტიპებზე „სიბრტყე“, „ამოწევა“ და „ბრუნვა“ +@msg Revolve maps graphics onto its side: map them onto the front ბრუნვა გრაფიკას თავის გვერდით ზედაპირზე ათავსებს: განათავსეთ ისინი წინა მხარეზე +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments გრაფიკა ზედმეტად რთულია ამ ზედაპირისთვის: გაამარტივეთ სიმბოლოების ნახატი ან შეამცირეთ სეგმენტები @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` იღებს მაქსიმუმ {MAX_GRAPHICS} გრაფიკას @msg A plane has one face: map its graphics onto the front სიბრტყეს ერთი მხარე აქვს: მისი გრაფიკა წინა მხარეზე განათავსეთ @msg The graphics are too complex: simplify the symbols' art გრაფიკა ზედმეტად რთულია: გაამარტივეთ სიმბოლოების ნახატი @@ -5740,6 +5746,7 @@ imageTrace Outline კონტური Arrange Intertwine Area გადახლართვის არის დალაგება @msg select an Intertwine group მონიშნეთ გადახლართვის ჯგუფი @msg select an Objects on Path group მონიშნეთ ტრაექტორიაზე ობიექტების ჯგუფი +@msg choose the object to detach with its Select/Move widget მოსახსნელი ობიექტი აირჩიეთ მისი არჩევა/გადატანის ვიჯეტით @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects შედგენილ ტრაექტორიაში, გადასვლაში, გარსში, გამეორებაში ან გადახლართვის ჯგუფში მყოფ ტრაექტორიაზე ობიექტების მიმაგრება ვერ ხერხდება @msg a clipping path can't take objects მომჭრელ ტრაექტორიაზე ობიექტების მიმაგრება ვერ ხერხდება @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path გადასვლაში, გარსში, გამეორებაში ან გადახლართვის ჯგუფში მყოფი ობიექტების ტრაექტორიაზე მიმაგრება ვერ ხერხდება @@ -6002,6 +6009,8 @@ imageTrace Outline კონტური Objects on Path Tool ტრაექტორიაზე ობიექტების ხელსაწყო Attach Objects on Path ობიექტების ტრაექტორიაზე მიმაგრება Expand Objects on Path ტრაექტორიაზე ობიექტების გაშლა + Detach მოხსნა + Detach Object from Path ობიექტის ტრაექტორიიდან მოხსნა Release Live Paint ცოცხალი შეღებვის მოხსნა Release Repeat გამეორების მოხსნა Release Slice ნაჭრის მოხსნა @@ -6260,7 +6269,7 @@ axis Y Axis Y ღერძი @msg `dither` applies to gradient fills (stroke: false) `dither` ვრცელდება გრადიენტულ შევსებებზე (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill) @msg `dither` must be true or false `dither` უნდა იყოს true ან false -@msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` უნდა იყოს classic, perceptual, hue-clockwise ან hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` უნდა იყოს pad, repeat ან reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` უნდა იყოს მთელი რიცხვი 1-დან {MAX_REPEATS}-მდე @msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და რომბულ გრადიენტებს @@ -6503,6 +6512,7 @@ axis Y Axis Y ღერძი Continue გაგრძელება Continue Action მოქმედების გაგრძელება Delete Action Step მოქმედების ნაბიჯის წაშლა + Move Action Step მოქმედების ნაბიჯის გადატანა Press Play Current Selection in the Actions panel to go on. გასაგრძელებლად მოქმედებების პანელზე დააჭირეთ „მიმდინარე მონიშნულის შესრულებას“. Stop: {message} გაჩერება: {message} @msg no action is waiting at a stop გაჩერებაზე არცერთი მოქმედება არ ელოდება @@ -6674,6 +6684,22 @@ axis Y Axis Y ღერძი Graphite Pencil გრაფიტის ფანქარი Wax Crayon ცვილის ფანქარი Charcoal Dust ნახშირის მტვერი + Notched Ribbon დაკბილული ლენტი + Double Line ორმაგი ხაზი + Wave ტალღა + Teardrop წვეთი + Striped Ribbon ზოლიანი ლენტი + Fine Nib წვრილი წვერი + Broad Nib განიერი წვერი + Chisel საჭრეთელი + Round Pen მრგვალი კალამი + Pressure Pen დაწოლის კალამი + Beads მძივები + Dash Dot ტირე-წერტილი + Scallops ფესტონები + Stars ვარსკვლავები + Triangles სამკუთხედები + Sparkles ნაპერწკლები Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის @msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries) @@ -6766,6 +6792,13 @@ swatch Bright Violet კაშკაშა იისფერი Area Text არის ტექსტი # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds ტექსტის გაზომვა გლიფის საზღვრებით +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options პიქსელზე მიბმის პარამეტრები + Pixel Snapping Options… პიქსელზე მიბმის პარამეტრები… + Snap to Pixel While Drawing პიქსელზე მიბმა ხატვისას + Snap to Pixel While Moving პიქსელზე მიბმა გადაადგილებისას + Snap to Pixel While Scaling პიქსელზე მიბმა მასშტაბირებისას + These options take effect when View › Snap to Pixel is on. ეს პარამეტრები მოქმედებს, როცა ხედი › პიქსელზე მიბმა ჩართულია. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options გლიფზე მიბმის პარამეტრების ჩვენება Hide Snap to Glyph Options გლიფზე მიბმის პარამეტრების დამალვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4f75bf3861..d8b9bd0c26 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1982,6 +1982,8 @@ antiAlias Sharp 날카롭게 Objects on Path Tool 패스 위 개체 도구 Attach Objects on Path 패스 위 개체 부착 Expand Objects on Path 패스 위 개체 확장 + Detach 분리 + Detach Object from Path 패스에서 개체 분리 Release Live Paint 라이브 페인트 해제 Release Repeat 반복 해제 Release Slice 분할 해제 @@ -4509,6 +4511,7 @@ theme Appearance 외관 @msg select a repeat 반복을 선택하세요 @msg select an Intertwine group 교차 그룹을 선택하세요 @msg select an Objects on Path group 패스 위 개체 그룹을 선택하세요 +@msg choose the object to detach with its Select/Move widget 분리할 개체를 선택/이동 위젯으로 고르세요 @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 컴파운드 패스, 블렌드, 엔벨로프, 반복 또는 교차 그룹 안의 패스에는 개체를 부착할 수 없습니다 @msg a clipping path can't take objects 클리핑 패스에는 개체를 부착할 수 없습니다 @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 블렌드, 엔벨로프, 반복 또는 교차 그룹 안의 개체는 패스에 부착할 수 없습니다 @@ -5115,6 +5118,7 @@ theme Appearance 외관 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}`은(는) VectorCraft 문서, PDF 또는 PDF 호환 .ai 파일이 아닙니다 @msg paths: give at least one file paths: 파일을 하나 이상 지정하세요 @msg replace: the object to replace was changed or deleted replace: 바꿀 개체가 변경되었거나 삭제되었습니다 +@msg the document it was prepared for is no longer active 준비한 문서가 더 이상 활성 상태가 아닙니다 @msg Couldn't place {name}: {e} {name}을(를) 가져올 수 없습니다: {e} @msg no print service 인쇄 서비스가 없습니다 @msg the print job has no data 인쇄 작업에 데이터가 없습니다 @@ -5178,6 +5182,7 @@ theme Appearance 외관 @msg every numbered name for it is taken in the Fonts folder 글꼴 폴더에서 이를 위한 번호 이름이 모두 사용 중입니다 @msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다 @msg No linked files to look for 찾을 링크된 파일이 없습니다 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 @msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 @@ -5542,7 +5547,9 @@ lighting Light 광원 @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`의 face는 front 또는 back이어야 합니다 @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`의 {key}은(는) {lo}에서 {hi} 사이의 숫자여야 합니다 @msg `{GRAPHICS}` must be an array `{GRAPHICS}`은(는) 배열이어야 합니다 -@msg Graphics map onto planar faces: use the Extrude or Plane object type 그래픽은 평평한 면에 매핑됩니다. 돌출 또는 평면 개체 유형을 사용하십시오 +@msg Graphics map onto the Plane, Extrude and Revolve object types 그래픽은 평면, 돌출, 회전체 개체 유형에 매핑됩니다 +@msg Revolve maps graphics onto its side: map them onto the front 회전체는 그래픽을 옆면에 매핑합니다. 앞면에 매핑하십시오 +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments 그래픽이 이 표면에 비해 너무 복잡합니다. 심볼의 아트를 단순화하거나 세그먼트를 줄이십시오 @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}`에는 그래픽을 최대 {MAX_GRAPHICS}개까지 넣을 수 있습니다 @msg A plane has one face: map its graphics onto the front 평면에는 면이 하나뿐입니다. 그래픽을 앞면에 매핑하십시오 @msg The graphics are too complex: simplify the symbols' art 그래픽이 너무 복잡합니다. 심볼의 아트를 단순화하십시오 @@ -5896,7 +5903,9 @@ axis Y Axis Y축 @msg `dither` applies to gradient fills (paint.setFill) `dither`는 그레이디언트 칠에 적용됩니다 (paint.setFill) @msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다 Perceptual 지각적 -@msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다 + Hue Clockwise 색조 시계 방향 + Hue Counterclockwise 색조 시계 반대 방향 +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method`는 classic, perceptual, hue-clockwise 또는 hue-counterclockwise여야 합니다 @msg `spread` must be pad, repeat or reflect `spread`는 pad, repeat 또는 reflect여야 합니다 @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats`는 1에서 {MAX_REPEATS} 사이의 정수여야 합니다 @msg `spread` applies to linear, radial, conic and diamond gradients `spread`는 선형, 방사형, 원뿔형 및 다이아몬드 그레이디언트에 적용됩니다 @@ -6139,6 +6148,7 @@ axis Y Axis Y축 Continue 계속 Continue Action 액션 계속 Delete Action Step 액션 단계 삭제 + Move Action Step 액션 단계 이동 Press Play Current Selection in the Actions panel to go on. 계속하려면 액션 패널에서 현재 선택 항목 재생을 누르세요. Stop: {message} 중지: {message} @msg no action is waiting at a stop 중지에서 대기 중인 액션이 없습니다 @@ -6310,6 +6320,22 @@ axis Y Axis Y축 Graphite Pencil 흑연 연필 Wax Crayon 왁스 크레용 Charcoal Dust 목탄 가루 + Notched Ribbon 노치 리본 + Double Line 이중선 + Wave 물결 + Teardrop 눈물방울 + Striped Ribbon 줄무늬 리본 + Fine Nib 가는 펜촉 + Broad Nib 넓은 펜촉 + Chisel 끌 + Round Pen 둥근 펜 + Pressure Pen 필압 펜 + Beads 구슬 + Dash Dot 일점쇄선 + Scallops 스캘럽 + Stars 별 + Triangles 삼각형 + Sparkles 반짝임 Save Brush Library… 브러시 라이브러리 저장… @msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다 @msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조) @@ -6329,6 +6355,13 @@ axis Y Axis Y축 Area Text 영역 문자 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 글리프 경계로 문자 측정 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options 픽셀에 스냅 옵션 + Pixel Snapping Options… 픽셀에 스냅 옵션… + Snap to Pixel While Drawing 그리는 동안 픽셀에 스냅 + Snap to Pixel While Moving 이동하는 동안 픽셀에 스냅 + Snap to Pixel While Scaling 크기를 조절하는 동안 픽셀에 스냅 + These options take effect when View › Snap to Pixel is on. 이 옵션은 보기 › 픽셀에 스냅이 켜져 있을 때 적용됩니다. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 글리프에 스냅 옵션 표시 Hide Snap to Glyph Options 글리프에 스냅 옵션 숨기기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0ba5b5cff7..02a196964c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -185,6 +185,7 @@ @msg node {_1} is on a locked artboard knooppunt {_1} staat op een vergrendeld tekengebied @msg thumbnail: no page {n} (the file has 1) miniatuur: geen pagina {n} (het bestand heeft er 1) @msg select an Objects on Path group selecteer een groep Objecten op pad +@msg choose the object to detach with its Select/Move widget kies het object dat je wilt loskoppelen met zijn widget Selecteren/verplaatsen @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects een pad binnen een samengesteld pad, mengsel, omvorming, herhaling of verstrengelingsgroep kan geen objecten opnemen @msg a clipping path can't take objects een knippad kan geen objecten opnemen @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objecten binnen een mengsel, omvorming, herhaling of verstrengelingsgroep kunnen niet aan een pad worden gekoppeld @@ -5133,6 +5134,7 @@ imageTrace Outline Contour @msg paths contain invalid coordinates paden bevatten ongeldige coördinaten @msg paths: give at least one file paden: geef minstens één bestand @msg replace: the object to replace was changed or deleted vervangen: het te vervangen object is gewijzigd of verwijderd +@msg the document it was prepared for is no longer active het document waarvoor het is voorbereid, is niet langer actief @msg pattern missing patroon ontbreekt @msg patterns nested too deeply are filled with mid-grey patronen die te diep genest zijn, worden gevuld met middelgrijs @msg percent must be above 0 and at most 6400, not {p} percent moet boven 0 en maximaal 6400 zijn, niet {p} @@ -5696,6 +5698,8 @@ imageTrace Outline Contour Vivid Levendig Spectrum Spectrum Perceptual Perceptueel + Hue Clockwise Tint met de klok mee + Hue Counterclockwise Tint tegen de klok in Relative Colorimetric Relatief colorimetrisch Absolute Colorimetric Absoluut colorimetrisch Exact Exact @@ -5909,6 +5913,9 @@ mark-style Japanese Japans @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} in `{GRAPHICS}` moet een getal van {lo} tot {hi} zijn @msg `{GRAPHICS}` must be an array `{GRAPHICS}` moet een array zijn @msg Graphics map onto planar faces: use the Extrude or Plane object type Illustraties worden op platte vlakken geplaatst: gebruik het objecttype Extruderen of Vlak +@msg Graphics map onto the Plane, Extrude and Revolve object types Illustraties worden op de objecttypen Vlak, Extruderen en Draaien geplaatst +@msg Revolve maps graphics onto its side: map them onto the front Draaien plaatst illustraties op de zijkant: plaats ze op de voorkant +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments De illustraties zijn te complex voor dit oppervlak: vereenvoudig de illustraties van de symbolen of verlaag Segmenten @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` neemt maximaal {MAX_GRAPHICS} illustraties op @msg A plane has one face: map its graphics onto the front Een vlak heeft één zijde: plaats de illustraties ervan op de voorkant @msg The graphics are too complex: simplify the symbols' art De illustraties zijn te complex: vereenvoudig de illustraties van de symbolen @@ -6416,6 +6423,8 @@ swatch Bright Violet Felviolet Objects on Path Tool Objecten-op-pad-gereedschap Attach Objects on Path Objecten aan pad koppelen Expand Objects on Path Objecten op pad uitbreiden + Detach Loskoppelen + Detach Object from Path Object van pad loskoppelen Release Live Paint Live Paint vrijgeven Release Repeat Herhaling vrijgeven Release Slice Slice vrijgeven @@ -6606,7 +6615,7 @@ axis Y Axis Y-as @msg `dither` applies to gradient fills (stroke: false) `dither` geldt voor verloopvullingen (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` geldt voor verloopvullingen (paint.setFill) @msg `dither` must be true or false `dither` moet true of false zijn -@msg `method` must be classic or perceptual `method` moet classic of perceptual zijn +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` moet classic, perceptual, hue-clockwise of hue-counterclockwise zijn # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? „{name}” installeren, dat om een groot rekenbudget vraagt? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Elke run kan tot {minutes} minuten duren en {times} keer zoveel werk doen als andere plug-ins mogen. Hij draait op de achtergrond en je kunt hem annuleren. @@ -6647,6 +6656,7 @@ axis Y Axis Y-as Continue Doorgaan Continue Action Actie voortzetten Delete Action Step Actiestap verwijderen + Move Action Step Actiestap verplaatsen Press Play Current Selection in the Actions panel to go on. Druk in het paneel Acties op Huidige selectie afspelen om door te gaan. Stop: {message} Stop: {message} @msg no action is waiting at a stop geen actie wacht bij een stop @@ -6807,6 +6817,22 @@ axis Y Axis Y-as Graphite Pencil Grafietpotlood Wax Crayon Waskrijt Charcoal Dust Houtskoolstof + Notched Ribbon Gekerfd lint + Double Line Dubbele lijn + Wave Golf + Teardrop Traan + Striped Ribbon Gestreept lint + Fine Nib Fijne penpunt + Broad Nib Brede penpunt + Chisel Beitel + Round Pen Ronde pen + Pressure Pen Drukpen + Beads Kralen + Dash Dot Streep-punt + Scallops Schulprand + Stars Sterren + Triangles Driehoeken + Sparkles Fonkelingen Save Brush Library… Penseelbibliotheek opslaan… @msg no empty artboards to delete geen lege tekengebieden om te verwijderen Scale Presets Schaalvoorinstellingen @@ -6818,6 +6844,13 @@ axis Y Axis Y-as Area Text Vlaktekst # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Tekst meten op glyphbegrenzing +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opties voor uitlijnen op pixel + Pixel Snapping Options… Opties voor uitlijnen op pixel… + Snap to Pixel While Drawing Uitlijnen op pixel bij tekenen + Snap to Pixel While Moving Uitlijnen op pixel bij verplaatsen + Snap to Pixel While Scaling Uitlijnen op pixel bij schalen + These options take effect when View › Snap to Pixel is on. Deze opties werken wanneer Beeld › Uitlijnen op pixel is ingeschakeld. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Opties voor uitlijnen op glyph tonen Hide Snap to Glyph Options Opties voor uitlijnen op glyph verbergen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2a74d3929d..b51f2c164c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1761,6 +1761,8 @@ digits Malayalam Malajalam Objects on Path Tool Narzędzie Obiekty na ścieżce Attach Objects on Path Dołącz obiekty do ścieżki Expand Objects on Path Rozszerz obiekty na ścieżce + Detach Odłącz + Detach Object from Path Odłącz obiekt od ścieżki Release Live Paint Uwolnij aktywne malowanie Release Repeat Uwolnij powtórzenie Release Slice Uwolnij plasterek @@ -4907,6 +4909,7 @@ imageTrace Outline Kontur @msg paths contain invalid coordinates ścieżki zawierają nieprawidłowe współrzędne @msg paths: give at least one file paths: podaj co najmniej jeden plik @msg replace: the object to replace was changed or deleted replace: obiekt do zastąpienia został zmieniony lub usunięty +@msg the document it was prepared for is no longer active dokument, dla którego to przygotowano, nie jest już aktywny @msg pattern missing brak wzorka @msg patterns nested too deeply are filled with mid-grey zbyt głęboko zagnieżdżone wzorki są wypełniane średnią szarością @msg percent must be above 0 and at most 6400, not {p} percent musi być większy od 0 i nie większy niż 6400, nie {p} @@ -4942,6 +4945,7 @@ imageTrace Outline Kontur @msg select a repeat zaznacz powtórzenie @msg select an Intertwine group zaznacz grupę przeplotu @msg select an Objects on Path group zaznacz grupę obiektów na ścieżce +@msg choose the object to detach with its Select/Move widget wybierz obiekt do odłączenia jego uchwytem Zaznacz/Przesuń @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects do ścieżki wewnątrz ścieżki złożonej, przejścia, koperty, powtórzenia lub grupy przeplotu nie można dołączyć obiektów @msg a clipping path can't take objects do ścieżki przycinającej nie można dołączyć obiektów @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path obiektów wewnątrz przejścia, koperty, powtórzenia lub grupy przeplotu nie można dołączyć do ścieżki @@ -5267,6 +5271,7 @@ imageTrace Outline Kontur @msg every numbered name for it is taken in the Fonts folder wszystkie numerowane nazwy dla niego są zajęte w folderze czcionek @msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu @msg No linked files to look for Brak połączonych plików do wyszukania +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} @msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} @@ -5566,6 +5571,8 @@ imageTrace Outline Kontur High Contrast 3 Wysoki kontrast 3 Pentagram Pentagram Perceptual Percepcyjna + Hue Clockwise Barwa zgodnie z ruchem wskazówek zegara + Hue Counterclockwise Barwa przeciwnie do ruchu wskazówek zegara Relative Colorimetric Kolorymetryczna względna Absolute Colorimetric Kolorymetryczna bezwzględna Exact Dokładne @@ -5794,7 +5801,9 @@ mark-style Japanese Japońskie @msg `{GRAPHICS}` face must be front or back face w `{GRAPHICS}` musi mieć wartość front lub back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} w `{GRAPHICS}` musi być liczbą od {lo} do {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` musi być tablicą -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiki są nakładane na płaskie ściany: użyj typu obiektu Wyciągnięcie lub Płaszczyzna +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafiki są nakładane na obiekty typu Płaszczyzna, Wyciągnięcie i Obrót +@msg Revolve maps graphics onto its side: map them onto the front Obrót nakłada grafiki na swoją powierzchnię boczną: umieść je na przedniej stronie +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafiki są zbyt złożone dla tej powierzchni: uprość rysunki symboli lub zmniejsz Segmenty @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` przyjmuje najwyżej {MAX_GRAPHICS} grafik @msg A plane has one face: map its graphics onto the front Płaszczyzna ma jedną stronę: umieść jej grafiki na przedniej stronie @msg The graphics are too complex: simplify the symbols' art Grafiki są zbyt złożone: uprość rysunki symboli @@ -6164,7 +6173,7 @@ axis Y Axis Oś Y @msg `dither` applies to gradient fills (stroke: false) `dither` dotyczy wypełnień gradientem (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill) @msg `dither` must be true or false `dither` musi mieć wartość true lub false -@msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` musi mieć wartość classic, perceptual, hue-clockwise lub hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` musi mieć wartość pad, repeat lub reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musi być liczbą całkowitą od 1 do {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` dotyczy gradientów liniowych, radialnych, stożkowych i rombowych @@ -6407,6 +6416,7 @@ axis Y Axis Oś Y Continue Kontynuuj Continue Action Kontynuuj operację Delete Action Step Usuń krok operacji + Move Action Step Przenieś krok operacji Press Play Current Selection in the Actions panel to go on. Aby kontynuować, naciśnij Odtwórz bieżące zaznaczenie w panelu Operacje. Stop: {message} Zatrzymanie: {message} @msg no action is waiting at a stop żadna operacja nie czeka na zatrzymaniu @@ -6578,6 +6588,22 @@ axis Y Axis Oś Y Graphite Pencil Ołówek grafitowy Wax Crayon Kredka woskowa Charcoal Dust Pył węglowy + Notched Ribbon Wstęga z nacięciami + Double Line Podwójna linia + Wave Fala + Teardrop Łza + Striped Ribbon Wstęga w paski + Fine Nib Cienka stalówka + Broad Nib Szeroka stalówka + Chisel Dłuto + Round Pen Okrągłe pióro + Pressure Pen Pióro z naciskiem + Beads Koraliki + Dash Dot Kreska-kropka + Scallops Feston + Stars Gwiazdy + Triangles Trójkąty + Sparkles Iskry Save Brush Library… Zapisz bibliotekę pędzli… @msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania @msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries) @@ -6597,6 +6623,13 @@ axis Y Axis Oś Y Area Text Tekst akapitowy # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mierz tekst według granic glifów +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opcje przyciągania do pikseli + Pixel Snapping Options… Opcje przyciągania do pikseli… + Snap to Pixel While Drawing Przyciągaj do pikseli podczas rysowania + Snap to Pixel While Moving Przyciągaj do pikseli podczas przesuwania + Snap to Pixel While Scaling Przyciągaj do pikseli podczas skalowania + These options take effect when View › Snap to Pixel is on. Te opcje działają, gdy włączona jest opcja Widok › Przyciągaj do pikseli. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Pokaż opcje przyciągania do glifów Hide Snap to Glyph Options Ukryj opcje przyciągania do glifów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a5dda39393..3604959868 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1410,6 +1410,8 @@ antiAlias Sharp Nítido Objects on Path Tool Ferramenta Objetos no Caminho Attach Objects on Path Anexar Objetos ao Caminho Expand Objects on Path Expandir Objetos no Caminho + Detach Desanexar + Detach Object from Path Desanexar Objeto do Caminho Release Live Paint Liberar Pintura ao Vivo Release Repeat Liberar Repetição Release Slice Liberar Fatia @@ -4229,6 +4231,7 @@ axis Both Ambos @msg paths contain invalid coordinates os traçados contêm coordenadas inválidas @msg paths: give at least one file paths: informe pelo menos um arquivo @msg replace: the object to replace was changed or deleted replace: o objeto a ser substituído foi alterado ou excluído +@msg the document it was prepared for is no longer active o documento para o qual foi preparado não está mais ativo @msg pattern missing padrão ausente @msg patterns nested too deeply are filled with mid-grey padrões aninhados com profundidade excessiva são preenchidos com cinza médio @msg percent must be above 0 and at most 6400, not {p} percent deve ser maior que 0 e no máximo 6400, não {p} @@ -4264,6 +4267,7 @@ axis Both Ambos @msg select a repeat selecione uma repetição @msg select an Intertwine group selecione um grupo entrelaçado @msg select an Objects on Path group selecione um grupo de objetos no caminho +@msg choose the object to detach with its Select/Move widget escolha o objeto a desanexar com o controle Selecionar/Mover dele @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects um caminho dentro de um caminho composto, mesclagem, envelope, repetição ou grupo entrelaçado não pode receber objetos @msg a clipping path can't take objects um caminho de recorte não pode receber objetos @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objetos dentro de uma mesclagem, envelope, repetição ou grupo entrelaçado não podem ser anexados a um caminho @@ -5368,6 +5372,7 @@ axis Both Ambos @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise o texto de área em molduras baixas demais para uma linha da fonte em que aparece recebeu molduras mais altas ({_1}): senão o texto não apareceria @msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento @msg No linked files to look for Nenhum arquivo vinculado para procurar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela De-Interlace Desentrelaçar Eliminate Eliminar Create New Fields by Criar Novos Campos por @@ -5641,6 +5646,8 @@ effect Regular Regular High Contrast 3 Alto Contraste 3 Pentagram Pentagrama Perceptual Perceptual + Hue Clockwise Tom em sentido horário + Hue Counterclockwise Tom em sentido anti-horário Relative Colorimetric Colorimétrico Relativo Absolute Colorimetric Colorimétrico Absoluto Exact Exato @@ -5912,7 +5919,9 @@ mark-style Japanese Japonesas @msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` deve ser front ou back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` deve ser um número de {lo} a {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve ser uma matriz -@msg Graphics map onto planar faces: use the Extrude or Plane object type Os gráficos são mapeados em faces planas: use o tipo de objeto Extrusão ou Plano +@msg Graphics map onto the Plane, Extrude and Revolve object types Os gráficos são mapeados nos tipos de objeto Plano, Extrusão e Revolver +@msg Revolve maps graphics onto its side: map them onto the front Revolver mapeia os gráficos na sua lateral: mapeie-os na face frontal +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Os gráficos são complexos demais para esta superfície: simplifique a arte dos símbolos ou reduza Segmentos @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` aceita no máximo {MAX_GRAPHICS} gráficos @msg A plane has one face: map its graphics onto the front Um plano tem uma única face: mapeie seus gráficos na face frontal @msg The graphics are too complex: simplify the symbols' art Os gráficos são complexos demais: simplifique a arte dos símbolos @@ -6224,7 +6233,7 @@ axis Y Axis Eixo Y @msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a preenchimentos em gradiente (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill) @msg `dither` must be true or false `dither` deve ser true ou false -@msg `method` must be classic or perceptual `method` deve ser classic ou perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` deve ser classic, perceptual, hue-clockwise ou hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` deve ser pad, repeat ou reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve ser um número inteiro de 1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a gradientes lineares, radiais, cônicos e em losango @@ -6467,6 +6476,7 @@ axis Y Axis Eixo Y Continue Continuar Continue Action Continuar Ação Delete Action Step Excluir Etapa da Ação + Move Action Step Mover Etapa da Ação Press Play Current Selection in the Actions panel to go on. Para continuar, pressione Reproduzir Seleção Atual no painel Ações. Stop: {message} Parada: {message} @msg no action is waiting at a stop nenhuma ação está aguardando em uma parada @@ -6638,6 +6648,22 @@ axis Y Axis Eixo Y Graphite Pencil Lápis de Grafite Wax Crayon Giz de Cera Charcoal Dust Pó de Carvão + Notched Ribbon Fita Entalhada + Double Line Linha Dupla + Wave Onda + Teardrop Lágrima + Striped Ribbon Fita Listrada + Fine Nib Pena Fina + Broad Nib Pena Larga + Chisel Cinzel + Round Pen Pena Redonda + Pressure Pen Pena de Pressão + Beads Contas + Dash Dot Traço-Ponto + Scallops Festões + Stars Estrelas + Triangles Triângulos + Sparkles Brilhos Save Brush Library… Salvar Biblioteca de Pincéis… @msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) @@ -6657,6 +6683,13 @@ axis Y Axis Eixo Y Area Text Texto de Área # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Medir Texto pelos Limites do Glifo +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Opções de Ajuste ao Pixel + Pixel Snapping Options… Opções de Ajuste ao Pixel… + Snap to Pixel While Drawing Ajustar ao Pixel ao Desenhar + Snap to Pixel While Moving Ajustar ao Pixel ao Mover + Snap to Pixel While Scaling Ajustar ao Pixel ao Dimensionar + These options take effect when View › Snap to Pixel is on. Essas opções têm efeito quando Exibir › Ajustar ao Pixel está ativado. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar Opções de Ajuste ao Glifo Hide Snap to Glyph Options Ocultar Opções de Ajuste ao Glifo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 107033ef55..a3832caa9f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1612,6 +1612,8 @@ file-filter Templates Шаблоны Objects on Path Tool Инструмент «Объекты на контуре» Attach Objects on Path Прикрепить объекты к контуру Expand Objects on Path Разобрать объекты на контуре + Detach Открепить + Detach Object from Path Открепить объект от контура Release Live Paint Освободить быструю заливку Release Repeat Освободить повтор Release Slice Освободить фрагмент @@ -4810,6 +4812,7 @@ axis Both Оба @msg paths contain invalid coordinates контуры содержат недопустимые координаты @msg paths: give at least one file paths: укажите как минимум один файл @msg replace: the object to replace was changed or deleted replace: заменяемый объект был изменён или удалён +@msg the document it was prepared for is no longer active документ, для которого это было подготовлено, больше не активен @msg pattern missing узор отсутствует @msg patterns nested too deeply are filled with mid-grey слишком глубоко вложенные узоры заполняются серым @msg percent must be above 0 and at most 6400, not {p} percent должен быть больше 0 и не более 6400, а не {p} @@ -4845,6 +4848,7 @@ axis Both Оба @msg select a repeat выделите повтор @msg select an Intertwine group выделите группу переплетения @msg select an Objects on Path group выделите группу объектов на контуре +@msg choose the object to detach with its Select/Move widget выберите открепляемый объект его элементом «Выбрать/Переместить» @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects к контуру внутри составного контура, перехода, оболочки, повтора или группы переплетения нельзя прикрепить объекты @msg a clipping path can't take objects к контуру обрезки нельзя прикрепить объекты @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path объекты внутри перехода, оболочки, повтора или группы переплетения нельзя прикрепить к контуру @@ -5169,6 +5173,7 @@ axis Both Оба @msg every numbered name for it is taken in the Fonts folder все нумерованные имена для него уже заняты в папке Fonts @msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа @msg No linked files to look for Нет связанных файлов для поиска +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы @@ -5566,7 +5571,9 @@ lighting Light Свет @msg `{GRAPHICS}` face must be front or back face в `{GRAPHICS}` должен быть front или back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} в `{GRAPHICS}` должен быть числом от {lo} до {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` должен быть массивом -@msg Graphics map onto planar faces: use the Extrude or Plane object type Графика наносится на плоские грани: используйте тип объекта «Выдавливание» или «Плоскость» +@msg Graphics map onto the Plane, Extrude and Revolve object types Графика наносится на объекты типов «Плоскость», «Выдавливание» и «Вращение» +@msg Revolve maps graphics onto its side: map them onto the front «Вращение» наносит графику на свою боковую поверхность: размещайте её на передней стороне +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Графика слишком сложна для этой поверхности: упростите иллюстрации символов или уменьшите «Сегменты» @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` принимает не более {MAX_GRAPHICS} графических элементов @msg A plane has one face: map its graphics onto the front У плоскости одна сторона: размещайте её графику на передней стороне @msg The graphics are too complex: simplify the symbols' art Графика слишком сложна: упростите иллюстрации символов @@ -5920,7 +5927,9 @@ axis Y Axis Ось Y @msg `dither` applies to gradient fills (paint.setFill) `dither` применяется к градиентным заливкам (paint.setFill) @msg `dither` must be true or false `dither` должно быть true или false Perceptual Перцепционный -@msg `method` must be classic or perceptual `method` должен быть classic или perceptual + Hue Clockwise Оттенок по часовой стрелке + Hue Counterclockwise Оттенок против часовой стрелки +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` должен быть classic, perceptual, hue-clockwise или hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` должен быть pad, repeat или reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` должен быть целым числом от 1 до {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` применяется к линейным, радиальным, коническим и ромбовидным градиентам @@ -6163,6 +6172,7 @@ axis Y Axis Ось Y Continue Продолжить Continue Action Продолжить действие Delete Action Step Удалить шаг действия + Move Action Step Переместить шаг действия Press Play Current Selection in the Actions panel to go on. Чтобы продолжить, нажмите «Воспроизвести текущее выделение» на панели «Действия». Stop: {message} Остановка: {message} @msg no action is waiting at a stop ни одно действие не ожидает на остановке @@ -6334,6 +6344,22 @@ axis Y Axis Ось Y Graphite Pencil Графитный карандаш Wax Crayon Восковой мелок Charcoal Dust Угольная пыль + Notched Ribbon Лента с насечками + Double Line Двойная линия + Wave Волна + Teardrop Капля + Striped Ribbon Полосатая лента + Fine Nib Тонкое перо + Broad Nib Широкое перо + Chisel Стамеска + Round Pen Круглое перо + Pressure Pen Перо с нажимом + Beads Бусины + Dash Dot Штрихпунктир + Scallops Фестоны + Stars Звёзды + Triangles Треугольники + Sparkles Искры Save Brush Library… Сохранить библиотеку кистей… @msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения @msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries) @@ -6719,6 +6745,13 @@ theme Appearance Оформление Area Text Текст в области # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Измерять текст по границам глифа +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Параметры привязки к пикселю + Pixel Snapping Options… Параметры привязки к пикселю… + Snap to Pixel While Drawing Привязать к пикселю при рисовании + Snap to Pixel While Moving Привязать к пикселю при перемещении + Snap to Pixel While Scaling Привязать к пикселю при масштабировании + These options take effect when View › Snap to Pixel is on. Эти параметры действуют, когда включено Просмотр › Привязать к пикселю. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показать параметры привязки к глифу Hide Snap to Glyph Options Скрыть параметры привязки к глифу diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4081c5e4c3..a49e5d60ec 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1550,6 +1550,8 @@ font-technology Variable Variabla Objects on Path Tool Verktyg för objekt på bana Attach Objects on Path Fäst objekt på bana Expand Objects on Path Expandera objekt på bana + Detach Lossa + Detach Object from Path Lossa objekt från bana Release Live Paint Släpp direktmålning Release Repeat Släpp upprepning Release Slice Släpp skiva @@ -4674,6 +4676,7 @@ axis Both Båda @msg paths contain invalid coordinates banorna innehåller ogiltiga koordinater @msg paths: give at least one file paths: ange minst en fil @msg replace: the object to replace was changed or deleted replace: objektet som skulle ersättas har ändrats eller tagits bort +@msg the document it was prepared for is no longer active dokumentet som det förbereddes för är inte längre aktivt @msg pattern missing mönster saknas @msg patterns nested too deeply are filled with mid-grey mönster som är kapslade för djupt fylls med mellangrått @msg percent must be above 0 and at most 6400, not {p} percent måste vara över 0 och högst 6400, inte {p} @@ -5027,6 +5030,7 @@ axis Both Båda @msg every numbered name for it is taken in the Fonts folder alla numrerade namn för den är upptagna i mappen Typsnitt @msg every numbered name for {name} is taken in the document's folder alla numrerade namn för {name} är upptagna i dokumentets mapp @msg No linked files to look for Inga länkade filer att leta efter +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Länken finns i en symbol, en diagramdesign, ett mönster eller en opacitetsmask och har ingen plats på arbetsytan Services Tjänster Hide VectorCraft Göm VectorCraft Zoom Zoom @@ -5434,7 +5438,9 @@ lighting Light Ljus @msg `{GRAPHICS}` face must be front or back face i `{GRAPHICS}` måste vara front eller back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} i `{GRAPHICS}` måste vara ett tal från {lo} till {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` måste vara en matris -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafik placeras på plana sidor: använd objekttypen Extrudera eller Plan +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafik placeras på objekttyperna Plan, Extrudera och Rotera runt axel +@msg Revolve maps graphics onto its side: map them onto the front Rotera runt axel placerar grafik på sin mantelyta: placera den på framsidan +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafiken är för komplex för den här ytan: förenkla symbolernas teckning eller minska Segment @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` tar högst {MAX_GRAPHICS} grafiker @msg A plane has one face: map its graphics onto the front Ett plan har en sida: placera dess grafik på framsidan @msg The graphics are too complex: simplify the symbols' art Grafiken är för komplex: förenkla symbolernas teckning @@ -5676,6 +5682,8 @@ lighting Light Ljus Pen Penna Pentagram Pentagram Perceptual Perceptuell + Hue Clockwise Nyans medurs + Hue Counterclockwise Nyans moturs Phone 360×800 Telefon 360×800 Phone 375×667 Telefon 375×667 Phone 375×812 Telefon 375×812 @@ -6044,6 +6052,7 @@ theme Appearance Utseende @msg no bound object reports a value (locked objects and embedded images report none) inget bundet objekt rapporterar ett värde (låsta objekt och inbäddade bilder rapporterar inget) @msg select an Intertwine group markera en sammanflätningsgrupp @msg select an Objects on Path group markera en grupp med objekt på bana +@msg choose the object to detach with its Select/Move widget välj objektet som ska lossas med dess Markera/Flytta-reglage @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects en bana inuti en sammansatt bana, en blandning, ett kuvert, en upprepning eller en sammanflätningsgrupp kan inte ta emot objekt @msg a clipping path can't take objects en klippbana kan inte ta emot objekt @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekt inuti en blandning, ett kuvert, en upprepning eller en sammanflätningsgrupp kan inte fästas på en bana @@ -6331,7 +6340,7 @@ axis Y Axis Y-axel @msg `dither` applies to gradient fills (stroke: false) `dither` gäller gradientfyllningar (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` gäller gradientfyllningar (paint.setFill) @msg `dither` must be true or false `dither` måste vara true eller false -@msg `method` must be classic or perceptual `method` måste vara classic eller perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` måste vara classic, perceptual, hue-clockwise eller hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` måste vara pad, repeat eller reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` måste vara ett heltal från 1 till {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` gäller för linjära, radiella och koniska gradienter och rombgradienter @@ -6533,6 +6542,7 @@ axis Y Axis Y-axel Continue Fortsätt Continue Action Fortsätt åtgärd Delete Action Step Radera åtgärdssteg + Move Action Step Flytta åtgärdssteg Press Play Current Selection in the Actions panel to go on. Fortsätt genom att trycka på Spela upp aktuell markering i panelen Åtgärder. Stop: {message} Stopp: {message} @msg no action is waiting at a stop ingen åtgärd väntar vid ett stopp @@ -6704,6 +6714,22 @@ axis Y Axis Y-axel Graphite Pencil Grafitpenna Wax Crayon Vaxkrita Charcoal Dust Koldamm + Notched Ribbon Hackat band + Double Line Dubbellinje + Wave Våg + Teardrop Droppe + Striped Ribbon Randigt band + Fine Nib Fin spets + Broad Nib Bred spets + Chisel Mejsel + Round Pen Rund penna + Pressure Pen Tryckpenna + Beads Pärlor + Dash Dot Streck-punkt + Scallops Bågkant + Stars Stjärnor + Triangles Trianglar + Sparkles Gnistor Save Brush Library… Spara penselbibliotek… @msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda @msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries) @@ -6723,6 +6749,13 @@ axis Y Axis Y-axel Area Text Områdestext # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Mät text efter glyfgränser +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Alternativ för fäst mot pixel + Pixel Snapping Options… Alternativ för fäst mot pixel… + Snap to Pixel While Drawing Fäst mot pixel vid ritning + Snap to Pixel While Moving Fäst mot pixel vid flyttning + Snap to Pixel While Scaling Fäst mot pixel vid skalning + These options take effect when View › Snap to Pixel is on. De här alternativen gäller när Visa › Fäst mot pixel är på. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Visa alternativ för fäst mot glyf Hide Snap to Glyph Options Dölj alternativ för fäst mot glyf diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 3ef1e7f28d..7dfa67889d 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -237,6 +237,23 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// The Objects on Path Options section's Detach button, its undo step and the message for a +/// Detach with no object chosen. +#[test] +fn objects_on_path_detach_strings_are_in_complete_catalogs() { + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for label in ["Detach", "Detach Object from Path"] { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } + let choose = "choose the object to detach with its Select/Move widget"; + assert!(message_literals().contains(choose)); + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + assert_ne!(message(lang, choose), choose, "{code}"); + } +} + /// The Window › Brush Libraries menu, built from the built-in library table, names each library. #[test] fn built_in_brush_library_names_are_in_complete_catalogs() { @@ -251,6 +268,30 @@ fn built_in_brush_library_names_are_in_complete_catalogs() { assert!(bad.is_empty(), "{}", bad.join("\n")); } +/// A built-in brush library's panel lists its brushes in the UI language, so every brush of every +/// built-in library has a row in each complete catalog. +#[test] +fn built_in_library_brush_names_are_in_complete_catalogs() { + use vectorcraft_engine::cmd::brushlib; + let session = vectorcraft_engine::Session::new(); + let mut names: Vec = brushlib::BUILTIN + .iter() + .filter_map(|b| brushlib::library(&session, b.id)) + .flat_map(|(_, lib)| lib.brushes.iter().map(|b| b.name.clone()).collect::>()) + .collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), 43, "{names:?}"); + let mut bad = vec![]; + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect(); + if !missing.is_empty() { + bad.push(format!("{} is missing {missing:?}", lang.code)); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); +} + /// Preference choices and the marquee's E hint share the same translated labels. #[test] fn marquee_selection_labels_are_in_complete_catalogs() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a02dc17285..f84a4575c4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4527,6 +4527,7 @@ axis Both Her İkisi @msg paths contain invalid coordinates yollar geçersiz koordinatlar içeriyor @msg paths: give at least one file yollar: en az bir dosya verin @msg replace: the object to replace was changed or deleted değiştir: değiştirilecek nesne değiştirildi veya silindi +@msg the document it was prepared for is no longer active hazırlandığı belge artık etkin değil @msg pattern missing desen eksik @msg patterns nested too deeply are filled with mid-grey çok derin iç içe geçmiş desenler orta gri renkle dolu @msg percent must be above 0 and at most 6400, not {p} percent 0'ın üzerinde ve en fazla 6400 olmalıdır, {p} değil @@ -4865,6 +4866,7 @@ axis Both Her İkisi @msg every numbered name for it is taken in the Fonts folder bunun için numaralandırılmış her ad Yazı Tipleri klasöründe alınır @msg every numbered name for {name} is taken in the document's folder {name} için numaralandırılmış her ad belgenin klasöründe alınır @msg No linked files to look for Aranacak bağlantılı dosya yok +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Bağlantı bir sembolün, grafik tasarımının, desenin veya opaklık maskesinin içinde ve tuvalde yeri yok Services Hizmetler Hide VectorCraft VectorCraft'ı gizle Zoom Yakınlaştır @@ -5229,7 +5231,9 @@ lighting Light Işık @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` face değeri front veya back olmalıdır @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` {key} değeri {lo} ile {hi} arasında bir sayı olmalıdır @msg `{GRAPHICS}` must be an array `{GRAPHICS}` bir dizi olmalıdır -@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafikler düz yüzlere eşlenir: Ekstrüzyon veya Düzlem nesne türünü kullan +@msg Graphics map onto the Plane, Extrude and Revolve object types Grafikler Düzlem, Ekstrüzyon ve Döndür nesne türlerine eşlenir +@msg Revolve maps graphics onto its side: map them onto the front Döndür grafikleri yan yüzeyine eşler: onları ön yüze eşle +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Grafikler bu yüzey için çok karmaşık: sembollerin resmini basitleştir veya Segmentler değerini azalt @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` en fazla {MAX_GRAPHICS} grafik alır @msg A plane has one face: map its graphics onto the front Bir düzlemin tek yüzü vardır: grafiklerini ön yüze eşle @msg The graphics are too complex: simplify the symbols' art Grafikler çok karmaşık: sembollerin resmini basitleştir @@ -5471,6 +5475,8 @@ lighting Light Işık Pen Kalem Pentagram Pentagram Perceptual algısal + Hue Clockwise Renk tonu saat yönünde + Hue Counterclockwise Renk tonu saat yönünün tersine Phone 360×800 Telefon 360×800 Phone 375×667 Telefon 375×667 Phone 375×812 Telefon 375×812 @@ -6002,6 +6008,7 @@ theme Appearance Görünüm @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for {name} veri kümesinden hiçbir şey uygulanmadı: değer içerdiği bir değişkene bağlı hiçbir nesne yok @msg no bound object reports a value (locked objects and embedded images report none) bağlı hiçbir nesne bir değer bildirmiyor (kilitli nesneler ve gömülü görüntüler değer bildirmez) @msg select an Objects on Path group bir Yol Üzerindeki Nesneler grubu seçin +@msg choose the object to detach with its Select/Move widget ayrılacak nesneyi Seç/Taşı denetimiyle seçin @msg the Simplify slider is not open Basitleştirme kaydırıcısı açık değil @msg the Smooth slider is not open Düzgünleştirme kaydırıcısı açık değil @msg {_1} is an exporter (File › Export › Export As…) {_1} bir dışa aktarma eklentisidir (Dosya › Dışa Aktar › Farklı Dışa Aktar…) @@ -6034,6 +6041,8 @@ theme Appearance Görünüm Chrome Krom Chrome… Krom… Expand Objects on Path Yol Üzerindeki Nesneleri Genişlet + Detach Ayır + Detach Object from Path Nesneyi Yoldan Ayır Graphic Pen Grafik Kalemi Graphic Pen… Grafik Kalemi… Move Anchor Point Bağlantı Noktasını Taşı @@ -6265,7 +6274,7 @@ axis Y Axis Y ekseni Optical Effects Optik Efektler Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular -@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` classic, perceptual, hue-clockwise veya hue-counterclockwise olmalıdır @msg `spread` must be pad, repeat or reflect `spread` pad, repeat veya reflect olmalıdır @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 ile {MAX_REPEATS} arasında bir tam sayı olmalıdır @msg `spread` applies to linear, radial, conic and diamond gradients `spread` doğrusal, radyal, konik ve elmas degradeler için geçerlidir @@ -6508,6 +6517,7 @@ axis Y Axis Y ekseni Continue Devam Et Continue Action Eyleme Devam Et Delete Action Step Eylem Adımını Sil + Move Action Step Eylem Adımını Taşı Press Play Current Selection in the Actions panel to go on. Devam etmek için Eylemler panelinde Geçerli Seçimi Oynat'a basın. Stop: {message} Durak: {message} @msg no action is waiting at a stop durakta bekleyen eylem yok @@ -6679,6 +6689,22 @@ axis Y Axis Y ekseni Graphite Pencil Grafit Kalem Wax Crayon Mum Boya Charcoal Dust Kömür Tozu + Notched Ribbon Çentikli Şerit + Double Line Çift Çizgi + Wave Dalga + Teardrop Gözyaşı + Striped Ribbon Çizgili Şerit + Fine Nib İnce Uç + Broad Nib Geniş Uç + Chisel Keski + Round Pen Yuvarlak Kalem + Pressure Pen Basınçlı Kalem + Beads Boncuklar + Dash Dot Çizgi Nokta + Scallops Fistolar + Stars Yıldızlar + Triangles Üçgenler + Sparkles Parıltılar Save Brush Library… Fırça Kitaplığını Kaydet… @msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok @msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries) @@ -6698,6 +6724,13 @@ axis Y Axis Y ekseni Area Text Alan Metni # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Yazıyı Glif Sınırlarına Göre Ölç +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Piksel'e Yapışma Seçenekleri + Pixel Snapping Options… Piksel'e Yapışma Seçenekleri… + Snap to Pixel While Drawing Çizerken Piksel'e Yapış + Snap to Pixel While Moving Taşırken Piksel'e Yapış + Snap to Pixel While Scaling Ölçeklerken Piksel'e Yapış + These options take effect when View › Snap to Pixel is on. Bu seçenekler Görünüm › Piksel'e Yapış açıkken etkili olur. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glif'e Yapışma Seçeneklerini Göster Hide Snap to Glyph Options Glif'e Yapışma Seçeneklerini Gizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 772e0203fe..772c3a7205 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1617,6 +1617,8 @@ file-filter Templates Шаблони Objects on Path Tool Інструмент «Об’єкти на контурі» Attach Objects on Path Прикріпити об’єкти до контуру Expand Objects on Path Розібрати об’єкти на контурі + Detach Відкріпити + Detach Object from Path Відкріпити об’єкт від контуру Release Live Paint Звільнити швидку заливку Release Repeat Звільнити повтор Release Slice Звільнити фрагмент @@ -4809,6 +4811,7 @@ axis Both Обидві @msg paths contain invalid coordinates контури містять неприпустимі координати @msg paths: give at least one file paths: вкажіть щонайменше один файл @msg replace: the object to replace was changed or deleted replace: об’єкт для заміни було змінено або видалено +@msg the document it was prepared for is no longer active документ, для якого це було підготовлено, більше не активний @msg pattern missing візерунок відсутній @msg patterns nested too deeply are filled with mid-grey надто глибоко вкладені візерунки заповнюються сірим @msg percent must be above 0 and at most 6400, not {p} percent має бути більше 0 і не більше 6400, а не {p} @@ -4844,6 +4847,7 @@ axis Both Обидві @msg select a repeat виділіть повтор @msg select an Intertwine group виділіть групу переплетення @msg select an Objects on Path group виділіть групу об’єктів на контурі +@msg choose the object to detach with its Select/Move widget виберіть об’єкт для відкріплення його елементом «Вибрати/Перемістити» @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects до контуру всередині складеного контуру, переходу, оболонки, повтору або групи переплетення не можна прикріпити об’єкти @msg a clipping path can't take objects до відсічного контуру не можна прикріпити об’єкти @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path об’єкти всередині переходу, оболонки, повтору або групи переплетення не можна прикріпити до контуру @@ -5168,6 +5172,7 @@ axis Both Обидві @msg every numbered name for it is taken in the Fonts folder усі нумеровані імена для нього вже зайняті в папці Fonts @msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа @msg No linked files to look for Немає пов’язаних файлів для пошуку +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб @@ -5561,7 +5566,9 @@ lighting Light Світло @msg `{GRAPHICS}` face must be front or back face у `{GRAPHICS}` має бути front або back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} у `{GRAPHICS}` має бути числом від {lo} до {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` має бути масивом -@msg Graphics map onto planar faces: use the Extrude or Plane object type Графіка наноситься на плоскі грані: використайте тип об’єкта «Видавлювання» або «Площина» +@msg Graphics map onto the Plane, Extrude and Revolve object types Графіка наноситься на об’єкти типів «Площина», «Видавлювання» та «Обертання» +@msg Revolve maps graphics onto its side: map them onto the front «Обертання» наносить графіку на свою бічну поверхню: розміщуйте її на передній стороні +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Графіка надто складна для цієї поверхні: спростіть ілюстрації символів або зменште «Сегменти» @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` приймає не більше {MAX_GRAPHICS} графічних елементів @msg A plane has one face: map its graphics onto the front Площина має одну сторону: розміщуйте її графіку на передній стороні @msg The graphics are too complex: simplify the symbols' art Графіка надто складна: спростіть ілюстрації символів @@ -5915,7 +5922,9 @@ axis Y Axis Вісь Y @msg `dither` applies to gradient fills (paint.setFill) `dither` застосовується до градієнтних заливок (paint.setFill) @msg `dither` must be true or false `dither` має бути true або false Perceptual Перцептивний -@msg `method` must be classic or perceptual `method` має бути classic або perceptual + Hue Clockwise Відтінок за годинниковою стрілкою + Hue Counterclockwise Відтінок проти годинникової стрілки +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` має бути classic, perceptual, hue-clockwise або hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` має бути pad, repeat або reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` має бути цілим числом від 1 до {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` застосовується до лінійних, радіальних, конічних і ромбоподібних градієнтів @@ -6158,6 +6167,7 @@ axis Y Axis Вісь Y Continue Продовжити Continue Action Продовжити дію Delete Action Step Видалити крок дії + Move Action Step Перемістити крок дії Press Play Current Selection in the Actions panel to go on. Щоб продовжити, натисніть «Відтворити поточне виділення» на панелі «Дії». Stop: {message} Зупинка: {message} @msg no action is waiting at a stop жодна дія не очікує на зупинці @@ -6329,6 +6339,22 @@ axis Y Axis Вісь Y Graphite Pencil Графітний олівець Wax Crayon Восковий олівець Charcoal Dust Вугільний пил + Notched Ribbon Стрічка з насічками + Double Line Подвійна лінія + Wave Хвиля + Teardrop Крапля + Striped Ribbon Смугаста стрічка + Fine Nib Тонке перо + Broad Nib Широке перо + Chisel Стамеска + Round Pen Кругле перо + Pressure Pen Перо з натиском + Beads Намистини + Dash Dot Штрихпунктир + Scallops Фестони + Stars Зірки + Triangles Трикутники + Sparkles Іскри Save Brush Library… Зберегти бібліотеку пензлів… @msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування @msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries) @@ -6348,6 +6374,13 @@ axis Y Axis Вісь Y Area Text Текст в області # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Вимірювати текст за межами гліфа +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Параметри прив’язки до пікселя + Pixel Snapping Options… Параметри прив’язки до пікселя… + Snap to Pixel While Drawing Прив’язати до пікселя під час малювання + Snap to Pixel While Moving Прив’язати до пікселя під час переміщення + Snap to Pixel While Scaling Прив’язати до пікселя під час масштабування + These options take effect when View › Snap to Pixel is on. Ці параметри діють, коли ввімкнено Перегляд › Прив’язати до пікселя. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показати параметри прив’язки до гліфа Hide Snap to Glyph Options Приховати параметри прив’язки до гліфа diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1fab0d34f5..3e0f534f99 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1851,6 +1851,8 @@ digits Hindi Hindi Objects on Path Tool Công cụ Đối tượng trên đường dẫn Attach Objects on Path Gắn đối tượng lên đường dẫn Expand Objects on Path Mở rộng đối tượng trên đường dẫn + Detach Tách + Detach Object from Path Tách đối tượng khỏi đường dẫn Release Live Paint Giải phóng tô trực tiếp Release Repeat Giải phóng lặp lại Release Slice Giải phóng lát cắt @@ -5005,6 +5007,7 @@ theme Appearance Giao diện @msg the library has another item of that kind named `{name}` thư viện đã có một mục khác cùng loại tên là `{name}` @msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list}) @msg No linked files to look for Không có tệp liên kết để tìm kiếm +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas @msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó @msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó @msg no native format không có định dạng gốc @@ -5146,6 +5149,7 @@ theme Appearance Giao diện @msg paths contain invalid coordinates đường dẫn chứa tọa độ không hợp lệ @msg paths: give at least one file đường dẫn: hãy cung cấp ít nhất một tệp @msg replace: the object to replace was changed or deleted thay thế: đối tượng cần thay thế đã bị thay đổi hoặc xóa +@msg the document it was prepared for is no longer active tài liệu được chuẩn bị không còn hoạt động @msg pattern missing thiếu họa tiết @msg patterns nested too deeply are filled with mid-grey họa tiết lồng nhau quá sâu được tô bằng màu xám trung tính @msg PDF settings are not an object Cài đặt PDF không phải là đối tượng @@ -5228,6 +5232,7 @@ theme Appearance Giao diện @msg select a repeat hãy chọn một đối tượng lặp @msg select an Intertwine group hãy chọn một nhóm đan xen @msg select an Objects on Path group hãy chọn một nhóm đối tượng trên đường dẫn +@msg choose the object to detach with its Select/Move widget hãy chọn đối tượng cần tách bằng tiện ích Chọn/Di chuyển của nó @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects không thể gắn đối tượng lên một đường dẫn nằm trong đường dẫn phức, đối tượng hòa trộn, Envelope, đối tượng lặp hoặc nhóm đan xen @msg a clipping path can't take objects không thể gắn đối tượng lên đường dẫn cắt @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path không thể gắn các đối tượng nằm trong đối tượng hòa trộn, Envelope, đối tượng lặp hoặc nhóm đan xen lên đường dẫn @@ -5348,7 +5353,9 @@ theme Appearance Giao diện @msg `{GRAPHICS}` face must be front or back face của `{GRAPHICS}` phải là front hoặc back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} của `{GRAPHICS}` phải là số từ {lo} đến {hi} @msg `{GRAPHICS}` must be an array `{GRAPHICS}` phải là một mảng -@msg Graphics map onto planar faces: use the Extrude or Plane object type Đồ họa được đặt lên các mặt phẳng: hãy dùng loại đối tượng Kéo dài hoặc Mặt phẳng +@msg Graphics map onto the Plane, Extrude and Revolve object types Đồ họa được đặt lên các loại đối tượng Mặt phẳng, Kéo dài và Xoay tròn +@msg Revolve maps graphics onto its side: map them onto the front Xoay tròn đặt đồ họa lên mặt bên của nó: hãy đặt chúng lên mặt trước +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments Đồ họa quá phức tạp cho bề mặt này: hãy đơn giản hóa hình vẽ của các ký hiệu hoặc giảm Đoạn @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nhận tối đa {MAX_GRAPHICS} đồ họa @msg A plane has one face: map its graphics onto the front Mặt phẳng chỉ có một mặt: hãy đặt đồ họa của nó lên mặt trước @msg The graphics are too complex: simplify the symbols' art Đồ họa quá phức tạp: hãy đơn giản hóa hình vẽ của các ký hiệu @@ -5916,7 +5923,9 @@ axis Y Axis Trục Y @msg `dither` applies to gradient fills (paint.setFill) `dither` áp dụng cho vùng tô chuyển sắc (paint.setFill) @msg `dither` must be true or false `dither` phải là true hoặc false Perceptual Theo cảm nhận -@msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual + Hue Clockwise Sắc độ theo chiều kim đồng hồ + Hue Counterclockwise Sắc độ ngược chiều kim đồng hồ +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` phải là classic, perceptual, hue-clockwise hoặc hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` phải là pad, repeat hoặc reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` phải là số nguyên từ 1 đến {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` áp dụng cho chuyển sắc tuyến tính, tỏa tròn, hình nón và hình thoi @@ -6159,6 +6168,7 @@ axis Y Axis Trục Y Continue Tiếp tục Continue Action Tiếp tục hành động Delete Action Step Xóa bước hành động + Move Action Step Di chuyển bước hành động Press Play Current Selection in the Actions panel to go on. Để tiếp tục, nhấn Phát vùng chọn hiện tại trong bảng Hành động. Stop: {message} Điểm dừng: {message} @msg no action is waiting at a stop không có hành động nào đang chờ ở điểm dừng @@ -6330,6 +6340,22 @@ axis Y Axis Trục Y Graphite Pencil Bút chì than chì Wax Crayon Bút sáp màu Charcoal Dust Bụi than + Notched Ribbon Dải băng khía + Double Line Đường đôi + Wave Sóng + Teardrop Giọt lệ + Striped Ribbon Dải băng sọc + Fine Nib Ngòi mảnh + Broad Nib Ngòi rộng + Chisel Đục + Round Pen Bút tròn + Pressure Pen Bút áp lực + Beads Hạt cườm + Dash Dot Gạch chấm + Scallops Viền vỏ sò + Stars Ngôi sao + Triangles Tam giác + Sparkles Lấp lánh Save Brush Library… Lưu thư viện cọ… @msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng @msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries) @@ -6349,6 +6375,13 @@ axis Y Axis Trục Y Area Text Chữ vùng # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds Đo chữ theo giới hạn glyph +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options Tùy chọn hút vào pixel + Pixel Snapping Options… Tùy chọn hút vào pixel… + Snap to Pixel While Drawing Hút vào pixel khi vẽ + Snap to Pixel While Moving Hút vào pixel khi di chuyển + Snap to Pixel While Scaling Hút vào pixel khi thay đổi tỉ lệ + These options take effect when View › Snap to Pixel is on. Các tùy chọn này có hiệu lực khi Hiển thị › Hút vào pixel đang bật. # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Hiện tùy chọn hút vào glyph Hide Snap to Glyph Options Ẩn tùy chọn hút vào glyph diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4dd0245ef4..2d2a8161c7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1543,6 +1543,8 @@ file-filter Templates 模板 Objects on Path Tool 路径上的对象工具 Attach Objects on Path 附加路径上的对象 Expand Objects on Path 扩展路径上的对象 + Detach 分离 + Detach Object from Path 将对象从路径分离 Release Live Paint 释放实时上色 Release Repeat 释放重复 Release Slice 释放切片 @@ -4280,6 +4282,7 @@ imageTrace Outline 轮廓 @msg select a repeat 请选择一个重复对象 @msg select an Intertwine group 请选择一个交织组 @msg select an Objects on Path group 请选择一个路径上的对象组 +@msg choose the object to detach with its Select/Move widget 请用对象的“选择/移动”控件选择要分离的对象 @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 复合路径、混合对象、封套、重复对象或交织组中的路径无法附加对象 @msg a clipping path can't take objects 剪切路径无法附加对象 @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 混合对象、封套、重复对象或交织组中的对象无法附加到路径 @@ -4871,6 +4874,7 @@ imageTrace Outline 轮廓 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文档、PDF 或 PDF 兼容的 .ai 文件 @msg paths: give at least one file paths:请至少给出一个文件 @msg replace: the object to replace was changed or deleted replace:要替换的对象已被更改或删除 +@msg the document it was prepared for is no longer active 为其准备的文档已不再处于活动状态 @msg Couldn't place {name}: {e} 无法置入 {name}:{e} @msg no print service 没有打印服务 @msg the print job has no data 打印作业没有数据 @@ -4931,6 +4935,7 @@ imageTrace Outline 轮廓 @msg every numbered name for it is taken in the Fonts folder 字体文件夹中所有带编号的名称均已被占用 @msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 @msg No linked files to look for 没有要查找的链接文件 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} @msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} @@ -5266,6 +5271,8 @@ imageTrace Outline 轮廓 Outlined Text 轮廓化的文字 All Rasterized Regions 所有栅格化区域 Perceptual 可感知 + Hue Clockwise 色相顺时针 + Hue Counterclockwise 色相逆时针 Relative Colorimetric 相对比色 Absolute Colorimetric 绝对比色 Simulate 模拟 @@ -5836,7 +5843,10 @@ lighting Light 光源 @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必须是 front 或 back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必须是 {lo} 到 {hi} 之间的数字 @msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必须是数组 -@msg Graphics map onto planar faces: use the Extrude or Plane object type 图形映射到平坦的面上:请使用突出或平面对象类型 +@msg Graphics map onto the Plane, Extrude and Revolve object types 图形映射到平面、突出和回转对象类型上 + Graphics map onto the Plane, Extrude and Revolve object types. 图形映射到平面、突出和回转对象类型上。 +@msg Revolve maps graphics onto its side: map them onto the front 回转将图形映射到其侧面:请将图形映射到正面 +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments 图形对此表面过于复杂:请简化符号的图稿或减少分段数 @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 个图形 @msg A plane has one face: map its graphics onto the front 平面只有一个面:请将其图形映射到正面 @msg The graphics are too complex: simplify the symbols' art 图形过于复杂:请简化符号的图稿 @@ -6147,7 +6157,7 @@ axis Y Axis Y 轴 @msg `dither` applies to gradient fills (stroke: false) `dither` 适用于渐变填色 (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill) @msg `dither` must be true or false `dither` 必须是 true 或 false -@msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` 必须是 classic、perceptual、hue-clockwise 或 hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` 必须是 pad、repeat 或 reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必须是 1 到 {MAX_REPEATS} 之间的整数 @msg `spread` applies to linear, radial, conic and diamond gradients `spread` 适用于线性、径向、圆锥和菱形渐变 @@ -6390,6 +6400,7 @@ axis Y Axis Y 轴 Continue 继续 Continue Action 继续动作 Delete Action Step 删除动作步骤 + Move Action Step 移动动作步骤 Press Play Current Selection in the Actions panel to go on. 要继续,请按“动作”面板中的“播放当前选择项目”。 Stop: {message} 停止:{message} @msg no action is waiting at a stop 没有动作在停止处等待 @@ -6561,6 +6572,22 @@ axis Y Axis Y 轴 Graphite Pencil 石墨铅笔 Wax Crayon 蜡笔 Charcoal Dust 炭粉 + Notched Ribbon 凹口丝带 + Double Line 双线 + Wave 波浪 + Teardrop 泪滴 + Striped Ribbon 条纹丝带 + Fine Nib 细笔尖 + Broad Nib 宽笔尖 + Chisel 凿形 + Round Pen 圆头笔 + Pressure Pen 压感笔 + Beads 珠串 + Dash Dot 点划线 + Scallops 扇贝边 + Stars 星形 + Triangles 三角形 + Sparkles 闪光 Save Brush Library… 保存画笔库… @msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔 @msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries) @@ -6580,6 +6607,13 @@ axis Y Axis Y 轴 Area Text 区域文字 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 按字形边界度量文字 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options 对齐像素选项 + Pixel Snapping Options… 对齐像素选项… + Snap to Pixel While Drawing 绘制时对齐像素 + Snap to Pixel While Moving 移动时对齐像素 + Snap to Pixel While Scaling 缩放时对齐像素 + These options take effect when View › Snap to Pixel is on. 这些选项在“视图 › 对齐像素”打开时生效。 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 显示靠齐字符选项 Hide Snap to Glyph Options 隐藏靠齐字符选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5004507d77..91fb7180e4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1588,6 +1588,8 @@ file-filter Templates 範本 Objects on Path Tool 路徑上的物件工具 Attach Objects on Path 附加路徑上的物件 Expand Objects on Path 展開路徑上的物件 + Detach 分離 + Detach Object from Path 將物件從路徑分離 Release Live Paint 釋放即時上色 Release Repeat 釋放重複 Release Slice 釋放切片 @@ -2021,6 +2023,7 @@ file-filter Templates 範本 @msg select a repeat 請選取重複物件 @msg select an Intertwine group 請選取交織群組 @msg select an Objects on Path group 請選取路徑上的物件群組 +@msg choose the object to detach with its Select/Move widget 請用物件的「選取/移動」控制項選取要分離的物件 @msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 複合路徑、漸變物件、封套、重複物件或交織群組中的路徑無法附加物件 @msg a clipping path can't take objects 剪裁路徑無法附加物件 @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 漸變物件、封套、重複物件或交織群組中的物件無法附加到路徑 @@ -2615,6 +2618,7 @@ file-filter Templates 範本 @msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文件、PDF 或 PDF 相容的 .ai 檔案 @msg paths: give at least one file paths:請提供至少一個檔案 @msg replace: the object to replace was changed or deleted replace:要取代的物件已被變更或刪除 +@msg the document it was prepared for is no longer active 為其準備的文件已不再處於使用中 @msg Couldn't place {name}: {e} 無法置入 {name}:{e} @msg no print service 沒有列印服務 @msg the print job has no data 列印工作沒有資料 @@ -2675,6 +2679,7 @@ file-filter Templates 範本 @msg every numbered name for it is taken in the Fonts folder 字體資料夾中所有編號名稱都已被使用 @msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用 @msg No linked files to look for 沒有要尋找的連結檔案 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} @msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} @@ -5669,7 +5674,10 @@ lighting Light 光源 @msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必須是 front 或 back @msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必須是介於 {lo} 到 {hi} 之間的數字 @msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必須是陣列 -@msg Graphics map onto planar faces: use the Extrude or Plane object type 圖形會對應到平坦的面:請使用「突出」或「平面」物件類型 +@msg Graphics map onto the Plane, Extrude and Revolve object types 圖形會對應到「平面」、「突出」與「迴轉」物件類型 + Graphics map onto the Plane, Extrude and Revolve object types. 圖形會對應到「平面」、「突出」與「迴轉」物件類型。 +@msg Revolve maps graphics onto its side: map them onto the front 迴轉會將圖形對應到其側面:請將圖形對應到正面 +@msg The graphics are too complex for this surface: simplify the symbols' art or reduce Segments 圖形對此表面過於複雜:請簡化符號的圖稿或減少分段數 @msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 個圖形 @msg A plane has one face: map its graphics onto the front 平面只有一個面:請將其圖形對應到正面 @msg The graphics are too complex: simplify the symbols' art 圖形過於複雜:請簡化符號的圖稿 @@ -5953,7 +5961,9 @@ axis Y Axis Y 軸 @msg `dither` applies to gradient fills (paint.setFill) `dither` 適用於漸層填色 (paint.setFill) @msg `dither` must be true or false `dither` 必須是 true 或 false Perceptual 感知 -@msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual + Hue Clockwise 色相順時針 + Hue Counterclockwise 色相逆時針 +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` 必須是 classic、perceptual、hue-clockwise 或 hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` 必須是 pad、repeat 或 reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必須是 1 到 {MAX_REPEATS} 之間的整數 @msg `spread` applies to linear, radial, conic and diamond gradients `spread` 適用於線性、放射狀、圓錐和菱形漸層 @@ -6196,6 +6206,7 @@ axis Y Axis Y 軸 Continue 繼續 Continue Action 繼續動作 Delete Action Step 刪除動作步驟 + Move Action Step 移動動作步驟 Press Play Current Selection in the Actions panel to go on. 若要繼續,請按「動作」面板中的「播放目前選取項目」。 Stop: {message} 停止:{message} @msg no action is waiting at a stop 沒有動作在停止處等待 @@ -6367,6 +6378,22 @@ axis Y Axis Y 軸 Graphite Pencil 石墨鉛筆 Wax Crayon 蠟筆 Charcoal Dust 炭粉 + Notched Ribbon 凹口緞帶 + Double Line 雙線 + Wave 波浪 + Teardrop 淚滴 + Striped Ribbon 條紋緞帶 + Fine Nib 細筆尖 + Broad Nib 寬筆尖 + Chisel 鑿形 + Round Pen 圓頭筆 + Pressure Pen 壓感筆 + Beads 珠串 + Dash Dot 點劃線 + Scallops 扇貝邊 + Stars 星形 + Triangles 三角形 + Sparkles 閃光 Save Brush Library… 儲存筆刷資料庫… @msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷 @msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries) @@ -6386,6 +6413,13 @@ axis Y Axis Y 軸 Area Text 區域文字 # Transform panel menu › Measure Type by Glyph Bounds. Measure Type by Glyph Bounds 依字符邊界測量文字 +# Control bar › Snapping › Pixel Snapping Options… (view.snapToPixel.options). + Pixel Snapping Options 靠齊像素選項 + Pixel Snapping Options… 靠齊像素選項… + Snap to Pixel While Drawing 繪製時靠齊像素 + Snap to Pixel While Moving 移動時靠齊像素 + Snap to Pixel While Scaling 縮放時靠齊像素 + These options take effect when View › Snap to Pixel is on. 這些選項會在「檢視 › 靠齊像素」開啟時生效。 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 顯示靠齊字符選項 Hide Snap to Glyph Options 隱藏靠齊字符選項 diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 5050529039..aaf2bd5b1f 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -28,7 +28,8 @@ pub fn hide_exports(app: &mut VectorcraftApp) -> Value { /// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG /// options…): every file it writes (one per artboard, linked images) goes next to `path`. The /// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are -/// several). +/// several). When automation asked for the export, `warnings` also name each option the written +/// format doesn't read, as `document.export`'s do. pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { // Options given as anything but an object are none: the path and format still reach the // encoder (an exporter plug-in is told the file it writes). @@ -62,6 +63,10 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option z, _ => f, }; + // An export that automation asked for, also through a save panel it opened, names the options + // the file doesn't read, as `document.export` does. A person's export gets no such notes. + let unused = if app.automation_asked() { fileio::unused_option_notes("document.export", f, params) } else { Vec::new() }; + let noted = unused.clone(); // Background Export: encoded and written from this snapshot while editing goes on. The // encoder is told the file it writes (an exporter plug-in gets its name and extension). let label = format!("Exporting {}", fileio::file_name(&path)); @@ -93,7 +98,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 1 { out["files"] = json!(files); } @@ -132,6 +143,11 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, /// The thread that checks the system clipboard for `system_paste` instead /// ([`Services::clipboard_probe`]). It is declared before `services` so that it is dropped - /// first: on X11 the last clipboard handle alive hands what the app copied to the clipboard - /// manager, and that must be the app's own handle in `services`, dropped on the UI thread - /// before the process exits. + /// first, waiting up to [`CLIPBOARD_PROBE_STOP_WAIT`] for its thread to let go of its handle: on + /// X11 the last clipboard handle alive hands what the app copied to the clipboard manager, and + /// that should be the app's own handle in `services`, dropped on the UI thread before the + /// process exits. clipboard_probe: Option, pub services: Services, pub canvas: CanvasCache, diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index abf43a3584..bc11e703e0 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -73,6 +73,10 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { if !st.selection.anchors.is_empty() { v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]); } + // Object › Path › Smooth…, which opens the Smooth slider (`invoke`). `prune` leaves it out + // unless object.path.smooth can run: the selection holds a path, in a group or a compound + // path too. + v.push(c("Smooth…", "object.path.smooth")); if several { v.push(c("Make Clipping Mask", "object.clippingMask.make")); } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 262e346c2c..3aab5e1e44 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -203,7 +203,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } } -/// The dialogs the Envelope, Variables, Pathfinder, Repeat, type, graph and View commands open when called without +/// The dialogs the Envelope, Variables, Pathfinder, Pixel Snapping, Repeat, type, graph and View commands open when called without /// params; whether `id` was one of them. fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. @@ -223,6 +223,11 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { crate::dialogs::pathfinder_options::open(app); return true; } + // Pixel Snapping Options: a dialog with the current options. + if id == "view.snapToPixel.options" && p.as_object().is_none_or(|o| o.is_empty()) { + crate::dialogs::pixel_snap_options::open(app); + return true; + } // Repeat Options: a dialog with the selected repeat's current values. if id == "object.repeat.options" && p.as_object().is_none_or(|o| o.is_empty()) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index efa4e95ac4..fa6b17a61b 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -989,6 +989,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::insert_select_object(app, p), crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), + crate::panels::actions::MOVE_STEP => crate::panels::actions::move_step(app, p), "ui.batchDialog" => { let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0)); index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action)) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 6defd9c020..37cdcb2d56 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -196,7 +196,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"), ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), - ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), + ( + "view.snapToPixel", + "Snap to Pixel", + "", + "{} toggle: drawing, moving and scaling land on whole pixels, each while its option in view.snapToPixel.options is on", + ), ( "view.snapToGlyph", "Snap to Glyph", @@ -1254,7 +1259,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth opens it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", @@ -1473,13 +1478,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::START_RECORDING, "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}", + "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}", ), ( crate::panels::actions::STOP_RECORDING, "Stop Playing/Recording", "", - "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it or the action ui.action.record named, at the place it named, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( crate::panels::actions::INSERT_STOP, @@ -1517,6 +1522,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", ), + ( + crate::panels::actions::MOVE_STEP, + "Move Action Step", + "", + "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}", + ), ( "ui.batchDialog", "Batch…", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index cbad3dd34f..62538d8f2f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -521,6 +521,34 @@ pub(crate) fn rows_take_delete(ctx: &egui::Context) -> bool { has_row_keys(ctx, layers::ROW_KEYS) || has_row_keys(ctx, appearance::ROW_KEYS) } +/// How near a scrolled list's top or bottom edge a drag scrolls it ([`scroll_at_edges`]). +const DRAG_SCROLL_EDGE: f32 = 28.0; + +/// The speed of that scrolling in points per second: at the band's inner side, and added to it at +/// the list's edge. +const DRAG_SCROLL_SPEED: (f32, f32) = (60.0, 280.0); + +/// While something is dragged near the top or bottom edge of a scrolled list (`ui`: its contents), +/// scroll that way, faster nearer the edge, and draw the next frame so a pointer held still keeps +/// it scrolling. The pointer must be over the list's width. Each frame scrolls by the time it +/// took (a stalled frame counts a tenth of a second at most), so the speed is the same at every +/// frame rate. +pub(crate) fn scroll_at_edges(ui: &mut Ui) { + let Some(pos) = ui.ctx().pointer_hover_pos() else { return }; + let view = ui.clip_rect(); + if !view.x_range().contains(pos.x) { + return; + } + let (slow, more) = DRAG_SCROLL_SPEED; + let speed = |depth: f32| if (0.0..DRAG_SCROLL_EDGE).contains(&depth) { slow + more * (1.0 - depth / DRAG_SCROLL_EDGE) } else { 0.0 }; + let per_second = speed(pos.y - view.top()) - speed(view.bottom() - pos.y); + if per_second != 0.0 { + let dt = ui.input(|i| i.stable_dt).min(0.1); + ui.scroll_with_delta_animation(vec2(0.0, per_second * dt), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + } +} + /// [`crate::i18n::label_or_name`] in the UI language. pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { crate::i18n::label_or_name(crate::i18n::current(), s, built_in) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 81e3074394..1971834a07 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -894,16 +894,27 @@ pub const STOP_RECORDING: &str = "ui.action.stop"; /// The command that begins a plain recording ([`record`]). pub const START_RECORDING: &str = "ui.action.record"; -/// `ui.action.record {set?}`: begins recording an automatically named Action N in `set`, or in -/// the current set (the first if none is current). With no sets, makes Set 1. Stop adds the action -/// after the set's existing actions. An explicit invalid set, an existing recording, or a set whose -/// new action would pass a limit of Load Actions… ([`edit_keeps_set_loadable`]: 1,000 actions, -/// 8 MiB) is an error and leaves the panel unchanged. While it records, a command whose step does not fit in the action -/// ([`recording_room`]) does not run. → {set, name} +/// `ui.action.record {set?, action?, after?}`: begins recording an automatically named Action N in +/// `set`, or in the current set (the first if none is current). With no sets, makes Set 1. Stop +/// adds the action after the set's existing actions. With `action`, it records into the action at +/// that index of set `set` instead: Stop puts the recorded steps after step `after` of that action, +/// else after its last step, and the steps after them keep whether they are excluded. The Begin +/// Recording button passes the selected action and step. `action` without `set`, `after` without +/// `action`, an index past the last set, action or step, an explicit invalid set, an existing +/// recording, or a set whose new action would pass a limit of Load Actions… +/// ([`edit_keeps_set_loadable`]: 1,000 actions, 8 MiB) is an error and leaves the panel unchanged. +/// While it records, a command whose step does not fit in the action ([`recording_room`]) does not +/// run. → {set, name}, and with `action` {set, action, name} pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { if app.ui.recording.is_some() { return Err("an action is already recording".into()); } + if p.get("action").is_some() { + return record_into(app, p); + } + if p.get("after").is_some() { + return Err("no such action".into()); + } let set = match p.get("set") { Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < app.ui.action_sets.len()).ok_or("no such action set")?, _ => { @@ -931,8 +942,32 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(json!({ "set": set, "name": name })) } +/// [`record`] with `action`: records into the action at index `action` of set `set`, after step +/// `after` or after its last step. The set becomes current and the only selected set, and the +/// action stays selected. → {set, action, name} +fn record_into(app: &mut VectorcraftApp, p: &Value) -> Result { + let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let (set, at) = + index("set").zip(index("action")).filter(|&(s, a)| app.ui.action_sets.get(s).is_some_and(|s| a < s.actions.len())).ok_or("no such action")?; + let action = &app.ui.action_sets[set].actions[at]; + let slot = match p.get("after") { + None => None, + Some(_) => Some(index("after").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1), + }; + let (id, name) = (action.id, action.name.clone()); + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; + start_recording(app, set, name.clone(), Some(id)); + app.ui.recording_at = slot; + if let Some(ctx) = app.ui_ctx.clone() { + set_pstate::>(&ctx, "actions-selected", Some((set, at))); + } + Ok(json!({ "set": set, "action": at, "name": name })) +} + /// `ui.action.stop {}`: ends the recording in progress and keeps its steps, in the action New -/// Action made for it, else in a new action after the last action of its set, as the bottom bar's +/// Action made for it or the action [`record`] named, at the place it named, else in a new action +/// after the last action of its set, as the bottom bar's /// Stop Playing/Recording does. It also ends a playback at Step by Step or Pause For, or one that /// waits at a stop, whose message box closes: the steps already played stay, and the rest do not /// run. With nothing recording or playing it changes nothing. Recorded steps past the limits of an @@ -951,6 +986,13 @@ pub const INSERT_STOP: &str = "ui.action.insertStop"; /// The panel state that holds the selected step as (the action's [`Action::id`], the step's index). const SELECTED_STEP: &str = "actions-selected-step"; +/// The panel state that holds the step being dragged as a [`DraggedStep`]. +const DRAGGED_STEP: &str = "actions-dragged-step"; + +/// A step being dragged: the action's [`Action::id`], the step's index, and the action's steps and +/// excluded steps as they were when the drag started. +type DraggedStep = (u64, usize, std::sync::Arc<(Vec<(String, Value)>, BTreeSet)>); + /// `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}`: inserts a stop. With `set` /// and `action`, it goes into that action, after step `step`, else after its last step. Without /// them, it goes into the recording in progress, after the steps recorded so far. `message` is @@ -1083,9 +1125,9 @@ fn recording_start(app: &VectorcraftApp) -> Option { } /// Where Stop Playing/Recording puts the steps of the recording in progress: the index of the set, -/// and of the action New Action made for it, or None for the new action Stop adds after the set's -/// last action. None when there is no recording, no set to put them in, or the action New Action -/// made for it was deleted, so that Stop keeps none of them. +/// and of the action it records into ([`crate::UiState::recording_into`]), or None for the new +/// action Stop adds after the set's last action. None when there is no recording, no set to put +/// them in, or the action it records into was deleted, so that Stop keeps none of them. fn recording_target(app: &VectorcraftApp) -> Option<(usize, Option)> { let (set, ..) = app.ui.recording.as_ref()?; let Some(id) = app.ui.recording_into else { @@ -1107,7 +1149,7 @@ fn set_with_recorded(app: &VectorcraftApp, steps: Vec<(String, Value)>) -> Optio s.actions.len() - 1 } }; - extend_recorded(s.actions.get_mut(at)?, steps); + put_recorded(s.actions.get_mut(at)?, steps, app.ui.recording_at); Some((s, at)) } @@ -1144,7 +1186,7 @@ fn set_sizes(app: &VectorcraftApp, at: Option, set: &ActionSet) -> [usize let mut s = set.clone(); let steps = app.session.journal_for_action(start); match app.ui.recording_into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { - Some(i) => extend_recorded(&mut s.actions[i], steps), + Some(i) => put_recorded(&mut s.actions[i], steps, app.ui.recording_at), None => s.actions.push(Action::new(name, steps)), } with = s; @@ -1254,36 +1296,56 @@ fn recorded_steps_fit(app: &mut VectorcraftApp) -> Result<(), String> { } /// Before the engine command `id` runs with `params`: Err when the recording in progress would keep -/// it as a step that does not fit in its action ([`recording_room`]); else whether the recording -/// would keep it, for [`after_recorded_command`]. A command is kept as a step when it is journaled, -/// a batch (`command.batch`, which journals itself) too, and Find Objects when it selects what it -/// finds. Undo and Redo are steps too, and are refused when they do not fit. -pub(crate) fn before_recorded_command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result { +/// it as a step that does not fit in its action ([`recording_room`]). A command is kept as a step +/// when it is journaled, a batch (`command.batch`, which journals itself) too, and Find Objects +/// when it selects what it finds. Undo and Redo are steps too, and are refused when they do not +/// fit. +pub(crate) fn before_recorded_command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result<(), String> { let journaled = vectorcraft_engine::find_command(id).is_some_and(|c| c.journal) || id == "command.batch" || (id == "document.find" && params.get("select").and_then(Value::as_bool) == Some(true)); if app.ui.recording.is_none() || !journaled || app.session.active().is_some_and(|d| d.interaction.is_some()) { - return Ok(false); + return Ok(()); } - recording_room(app, id, params)?; - Ok(true) + recording_room(app, id, params) } -/// After a command that the recording keeps as a step (`recorded`, [`before_recorded_command`]) has -/// run with result `r`: when the values the command added to its step as it ran make the steps no -/// longer fit ([`recorded_steps_fit`]), the command's edit and its result stay, the status bar -/// names the limit, no later recorded command runs, and Stop Playing/Recording leaves the step out -/// ([`steps_that_fit`]) → the command's result. -pub(crate) fn after_recorded_command(app: &mut VectorcraftApp, recorded: bool, r: Result) -> Result { - if recorded - && r.is_ok() - && let Err(e) = recorded_steps_fit(app) - { - app.status(e); +/// After an engine command has run with result `r`: when the steps it journaled, the values a +/// command added to its step as it ran or the commands a script ran (`script.run`), make the steps +/// no longer fit ([`after_journaled_edit`]), the command's edit and its result stay, the status bar +/// names the limit, no later recorded command runs, and Stop Playing/Recording leaves the steps +/// out ([`steps_that_fit`]) → the command's result. +pub(crate) fn after_recorded_command(app: &mut VectorcraftApp, r: Result) -> Result { + if r.is_ok() { + after_journaled_edit(app); } r } +/// After an edit that journals without [`before_recorded_command`]'s check (a script run's +/// commands, a tool's or a panel's edit, a dialog's commit): when the journal changed since the +/// last check and the steps that the recording in progress keeps no longer fit in its action +/// ([`recorded_steps_fit`]), the edit and the journal stay, the status bar names the limit, no +/// later recorded command runs, and Stop Playing/Recording keeps the steps that fit +/// ([`steps_that_fit`]) → whether the status bar names a limit. +pub(crate) fn after_journaled_edit(app: &mut VectorcraftApp) -> bool { + if app.ui.recording.is_none() { + return false; + } + let journal = (app.session.journal.rewrites(), app.session.journal.len()); + if app.ui.recording_checked == Some(journal) { + return false; + } + app.ui.recording_checked = Some(journal); + match recorded_steps_fit(app) { + Ok(()) => false, + Err(e) => { + app.status(e); + true + } + } +} + /// What Stop Playing/Recording adds to a set ([`recorded_into`]). struct Recorded { /// The set's action that the recording records into; None for the new action that the @@ -1339,12 +1401,17 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< let excluded = action.excluded_steps.clone(); action.insert_step(after, step); let id = action.id; + // The recording's steps stay after the steps that were before them. + let slot = match (&recorded, app.ui.recording_at) { + (Some(r), Some(slot)) if r.into == Some(at) && after <= slot => Some(slot + 1), + (_, slot) => slot, + }; let file_len = match recorded { None => files::write_set(s).len(), Some(Recorded { into, name, steps }) => { let mut kept = s.clone(); match into { - Some(into) => kept.actions[into].steps.extend(steps), + Some(into) => put_recorded(&mut kept.actions[into], steps, slot), None => kept.actions.push(Action::new(name, steps)), } files::write_set(&kept).len() @@ -1356,6 +1423,7 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< action.excluded_steps = excluded; return Err("the action set would be larger than 8 MiB".into()); } + app.ui.recording_at = slot; app.ui.action_set = Some(set); app.ui.action_sets_selected = vec![set]; if let Some(ctx) = app.ui_ctx.clone() { @@ -1395,6 +1463,12 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result let step = index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")?; action.remove_step(step); let id = action.id; + // The recording's steps stay after the steps that were before them. + if app.ui.recording_into == Some(id) + && let Some(slot) = app.ui.recording_at.filter(|&slot| step < slot) + { + app.ui.recording_at = Some(slot - 1); + } if let Some(ctx) = app.ui_ctx.clone() && pstate::>(&ctx, SELECTED_STEP).is_some_and(|(selected, _)| selected == id) { @@ -1417,6 +1491,115 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result Ok(json!({"set": set, "action": at, "step": step})) } +/// The command that moves one step of an action ([`move_step`]). +pub const MOVE_STEP: &str = "ui.action.moveStep"; + +/// `ui.action.moveStep {set, action, step, toSet?, toAction?, before}`: moves step `step`, a stop or +/// a command, of the action at index `action` of set `set` into the action at index `toAction` of +/// set `toSet` (default: the same set and action), before the step at index `before` there, or +/// after its last step when `before` is its number of steps. `before` counts the steps as they are +/// before the move. The step keeps its params and whether it is excluded, and the other steps keep +/// theirs. A selected step stays selected: a moved selected step makes its new action the selected +/// one and its set the current one. A Record Stop, Insert Menu Item or Set Selection dialog open on +/// a step follows the step, and the steps a recording adds to either action stay after the steps +/// that were before them. A step put back just before or just after itself does not move. A missing +/// or invalid index, or a move that would give a set a file that Load Actions… refuses +/// ([`edit_keeps_set_loadable`]: 10,000 steps in an action, 8 MiB), is an error, and then nothing +/// changes. Undo does not move the step back. A drag of a step row in the panel runs this. +/// → {set, action, step, moved}: where the step is now +pub fn move_step(app: &mut VectorcraftApp, p: &Value) -> Result { + let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()); + let index = |k: &str| p.get(k).and_then(number); + let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; + let source = app.ui.action_sets.get(set).and_then(|s| s.actions.get(at)).ok_or("no such action")?; + let step = index("step").filter(|&s| s < source.steps.len()).ok_or("no such action step")?; + let source_id = source.id; + let to_set = match p.get("toSet") { + None => set, + Some(v) => number(v).filter(|&s| s < app.ui.action_sets.len()).ok_or("no such action set")?, + }; + let to_action = match p.get("toAction") { + None => at, + Some(v) => number(v).ok_or("no such action")?, + }; + let dest = app.ui.action_sets[to_set].actions.get(to_action).ok_or("no such action")?; + let dest_id = dest.id; + let before = index("before").filter(|&b| b <= dest.steps.len()).ok_or("no such action step")?; + let same = (set, at) == (to_set, to_action); + if same && (before == step || before == step + 1) { + return Ok(json!({"set": set, "action": at, "step": step, "moved": false})); + } + // The step's index once it is in place, counted without it in its own action. + let to = if same && before > step { before - 1 } else { before }; + let mut from_edit = app.ui.action_sets[set].clone(); + let moved = from_edit.actions[at].steps[step].clone(); + let excluded = from_edit.actions[at].excluded_steps.contains(&step); + from_edit.actions[at].remove_step(step); + let put = |a: &mut Action| { + a.insert_step(to, moved); + if excluded { + a.excluded_steps.insert(to); + } + }; + let to_edit = if to_set == set { + put(&mut from_edit.actions[to_action]); + None + } else { + let mut edit = app.ui.action_sets[to_set].clone(); + put(&mut edit.actions[to_action]); + Some(edit) + }; + edit_keeps_set_loadable(app, Some(set), &from_edit)?; + if let Some(edit) = &to_edit { + edit_keeps_set_loadable(app, Some(to_set), edit)?; + } + app.ui.action_sets[set] = from_edit; + if let Some(edit) = to_edit { + app.ui.action_sets[to_set] = edit; + } + // A step index in `source_id` or `dest_id` as (the action's id, the step) after the move. + let follow = |(id, s): (u64, usize)| -> (u64, usize) { + if (id, s) == (source_id, step) { + return (dest_id, to); + } + let s = if id == source_id && s > step { s - 1 } else { s }; + (id, if id == dest_id && s >= to { s + 1 } else { s }) + }; + // The recording's steps stay after the steps that were before them. + if let Some(slot) = app.ui.recording_at { + let slot = if app.ui.recording_into == Some(source_id) && step < slot { slot - 1 } else { slot }; + app.ui.recording_at = Some(if app.ui.recording_into == Some(dest_id) && to <= slot { slot + 1 } else { slot }); + } + if let Some(ctx) = app.ui_ctx.clone() + && let Some(selected) = pstate::>(&ctx, SELECTED_STEP) + { + set_pstate::>(&ctx, SELECTED_STEP, Some(follow(selected))); + if selected == (source_id, step) { + set_pstate::>(&ctx, "actions-selected", Some((to_set, to_action))); + let mut expanded: BTreeSet = pstate(&ctx, "actions-expanded"); + expanded.insert(dest_id); + set_pstate(&ctx, "actions-expanded", expanded); + app.ui.action_set = Some(to_set); + app.ui.action_sets_selected = vec![to_set]; + } + } + // A Record Stop, Insert Menu Item or Set Selection dialog open on a step of either action follows it. + let either = |s: usize, a: usize| (s, a) == (set, at) || (s, a) == (to_set, to_action); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d)) + && let (Some(ds), Some(da), Some(ds_step)) = + (d.fields.get("set").and_then(number), d.fields.get("action").and_then(number), d.fields.get("step").and_then(number)) + && either(ds, da) + { + let id = if (ds, da) == (set, at) { source_id } else { dest_id }; + let (id, s) = follow((id, ds_step)); + let (ds, da) = if id == dest_id { (to_set, to_action) } else { (set, at) }; + d.fields.insert("set".into(), json!(ds)); + d.fields.insert("action".into(), json!(da)); + d.fields.insert("step".into(), json!(s)); + } + Ok(json!({"set": to_set, "action": to_action, "step": to, "moved": true})) +} + /// The command that `chord` runs under `km`, pressed once or pressed twice. #[cfg(test)] fn command_on(km: &craft_keymap::Keymap, chord: &craft_keymap::Chord) -> Option { @@ -1451,8 +1634,10 @@ fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Opt app.session.journal.hold(Some(start)); app.ui.recording = Some((set, name, start)); app.ui.recording_into = into; + app.ui.recording_at = None; app.ui.recording_base = None; app.ui.recording_tail = None; + app.ui.recording_checked = Some((app.session.journal.rewrites(), start)); } /// How many of `steps`, from the first, Stop Playing/Recording can give the recording's action so @@ -1477,12 +1662,15 @@ fn steps_that_fit(app: &VectorcraftApp, steps: &[(String, Value)]) -> (usize, Op (keep, (keep < steps.len()).then_some("the action already has 10,000 steps")) } -/// Adds the recorded `steps` after the steps of `action`, included in playback. An exclusion past -/// the action's last step, which saved preferences can hold, goes. -fn extend_recorded(action: &mut Action, steps: Vec<(String, Value)>) { +/// Puts the recorded `steps` into `action` before its step at index `at`, or after its last step +/// when `at` is None or past it, included in playback. The steps after them keep whether they are +/// excluded. An exclusion past the action's last step, which saved preferences can hold, goes. +fn put_recorded(action: &mut Action, steps: Vec<(String, Value)>, at: Option) { let len = action.steps.len(); - action.excluded_steps.retain(|&i| i < len); - action.steps.extend(steps); + let at = at.map_or(len, |at| at.min(len)); + let added = steps.len(); + action.excluded_steps = action.excluded_steps.iter().filter(|&&i| i < len).map(|&i| if i >= at { i + added } else { i }).collect(); + action.steps.splice(at..at, steps); } /// Stop Playing/Recording: ends the recording and keeps its steps (see [`start_recording`]). An @@ -1499,11 +1687,18 @@ fn stop_recording(app: &mut VectorcraftApp) { let Some((set, name, _)) = app.ui.recording.take() else { return }; app.ui.recording_base = None; app.ui.recording_tail = None; + app.ui.recording_checked = None; app.session.journal.hold(None); + let at = app.ui.recording_at.take(); match app.ui.recording_into.take() { Some(id) => { - if let Some(a) = app.ui.action_sets.iter_mut().flat_map(|s| s.actions.iter_mut()).find(|a| a.id == id) { - extend_recorded(a, steps); + let found = app.ui.action_sets.iter().enumerate().find_map(|(s, set)| Some((s, set.actions.iter().position(|a| a.id == id)?))); + if let Some((s, a)) = found { + let action = &mut app.ui.action_sets[s].actions[a]; + let len = action.steps.len(); + let (from, added) = (at.map_or(len, |at| at.min(len)), steps.len()); + put_recorded(action, steps, at); + follow_recorded(app, (s, a, id), from, added); } } None => { @@ -1514,6 +1709,28 @@ fn stop_recording(app: &mut VectorcraftApp) { } } +/// After Stop Playing/Recording put `added` steps into the action at `place` (set, action, id) +/// before its step at index `from`: the selected step and a Record Stop, Insert Menu Item or Set +/// Selection dialog open on a later step of the action follow their step down. +fn follow_recorded(app: &mut VectorcraftApp, (set, at, id): (usize, usize, u64), from: usize, added: usize) { + if added == 0 { + return; + } + if let Some(ctx) = app.ui_ctx.clone() + && let Some((step_id, step)) = pstate::>(&ctx, SELECTED_STEP).filter(|&(step_id, step)| step_id == id && step >= from) + { + set_pstate::>(&ctx, SELECTED_STEP, Some((step_id, step + added))); + } + let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d)) + && d.fields.get("set").and_then(number) == Some(set) + && d.fields.get("action").and_then(number) == Some(at) + && let Some(step) = d.fields.get("step").and_then(number).filter(|&step| step >= from) + { + d.fields.insert("step".into(), json!(step + added)); + } +} + crate::shortcut_editor::ui_mirror!(fn function_key_chords() -> Vec); /// Mirrors the actions' function keys for code without app access (cheap when unchanged). @@ -1651,6 +1868,7 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result if sets.contains(rec) { app.ui.recording = None; app.ui.recording_into = None; + app.ui.recording_at = None; app.session.journal.hold(None); } else { *rec -= sets.iter().filter(|&&s| s < *rec).count(); @@ -1904,6 +2122,34 @@ fn inclusion_box( (response.on_hover_text(if included { tl!("Exclude from playback") } else { tl!("Include in playback") }).clicked(), hit) } +/// While a step is dragged over the list: where it goes as (set, action, `before` of [`MOVE_STEP`]), +/// shown by a line in `color` on the row under the pointer, or None off every action's rows +/// (`slots`, as [`show`] lists them). Near the list's top or bottom edge, the list scrolls. +fn drop_target(ui: &mut egui::Ui, slots: &[(egui::Rect, usize, usize, usize, usize)], color: egui::Color32) -> Option<(usize, usize, usize)> { + let p = ui.ctx().pointer_interact_pos()?; + let clip = ui.clip_rect(); + if !clip.contains(p) { + return None; + } + let edge = 12.0; + let scroll = if p.y < clip.top() + edge { + 6.0 + } else if p.y > clip.bottom() - edge { + -6.0 + } else { + 0.0 + }; + if scroll != 0.0 && ui.input(|i| i.pointer.any_down()) { + ui.scroll_with_delta(egui::vec2(0.0, scroll)); + ui.ctx().request_repaint(); + } + let half_gap = ui.spacing().item_spacing.y / 2.0; + let &(r, si, ai, upper, lower) = slots.iter().find(|(r, ..)| (r.top() - half_gap..r.bottom() + half_gap).contains(&p.y))?; + let (before, y) = if upper != lower && p.y < r.center().y { (upper, r.top()) } else { (lower, r.bottom()) }; + ui.painter().line_segment([egui::pos2(r.left() + 42.0, y), egui::pos2(r.right(), y)], egui::Stroke::new(2.0, color)); + Some((si, ai, before)) +} + enum InclusionChange { Set(usize), Action(usize, usize), @@ -1932,6 +2178,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let mut expanded: BTreeSet = pstate(ui.ctx(), "actions-expanded"); let mut inclusion_changes = Vec::new(); let mut play_req = None; + // The step being dragged, from the frame its drag started. The drag ends without a move when a + // command since then changed its action's steps or which of them are excluded, or deleted the + // action. + let dragging = pstate::>(ui.ctx(), DRAGGED_STEP).filter(|(id, _, was)| { + let action = app.ui.action_sets.iter().flat_map(|s| &s.actions).find(|a| a.id == *id); + action.is_some_and(|a| (&a.steps, &a.excluded_steps) == (&was.0, &was.1)) + }); + let mut drag_started = None; + let mut drag_stopped = false; + // The rows a dragged step can be dropped on: (row, set, action, where it goes on the row's upper + // half, where on its lower half), as `before` of [`MOVE_STEP`]. + let mut slots: Vec<(egui::Rect, usize, usize, usize, usize)> = Vec::new(); + let mut drop = None; let list_height = widgets::panel_list_height(ui, 260.0, 0.0).min(260.0); egui::ScrollArea::vertical().max_height(list_height).min_scrolled_height(list_height.min(64.0)).show(ui, |ui| { for (si, set) in app.ui.action_sets.iter().enumerate() { @@ -2017,9 +2276,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if resp.double_clicked() && !resp.interact_pointer_pos().is_some_and(|p| check_rect.contains(p) || disclosure.contains(p)) { play_req = Some((si, ai)); } + // A step dropped on an action's row goes first when the action shows its steps, else + // after its last step. + let first = if open && !a.steps.is_empty() { 0 } else { a.steps.len() }; + slots.push((r, si, ai, first, first)); if open { for (step, (command, params)) in a.steps.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click_and_drag()); + slots.push((r, si, ai, step, step + 1)); + if resp.drag_started() { + drag_started = Some((a.id, step, std::sync::Arc::new((a.steps.clone(), a.excluded_steps.clone())))); + } + drag_stopped |= resp.drag_stopped(); if step_selected == Some((a.id, step)) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { @@ -2063,7 +2331,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } } + if dragging.is_some() { + drop = drop_target(ui, &slots, t.accent); + } }); + // A released drag moves the step once, to where the line showed. + let dragged = dragging.as_ref().map(|(id, step, _)| (*id, *step)); + let move_req = drag_stopped.then_some(dragged.zip(drop)).flatten().and_then(|((id, step), (si, ai, before))| { + let (fs, fa) = app.ui.action_sets.iter().enumerate().find_map(|(fs, s)| Some((fs, s.actions.iter().position(|a| a.id == id)?)))?; + Some(json!({"set": fs, "action": fa, "step": step, "toSet": si, "toAction": ai, "before": before})) + }); + let still_dragging = !drag_stopped && ui.input(|i| i.pointer.any_down()); + set_pstate(ui.ctx(), DRAGGED_STEP, drag_started.or(dragging.filter(|_| still_dragging))); if waiting_at_stop(app) { dim_label(ui, tl!("Press Play Current Selection in the Actions panel to go on.")); } @@ -2092,6 +2371,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { set_pstate(ui.ctx(), SELECTED_STEP, step_selected); app.ui.action_set = current; app.ui.action_sets_selected = sets_selected; + if let Some(params) = move_req + && let Err(e) = app.run(MOVE_STEP, params) + { + app.status(e); + } + // Begin Recording records into the selected action, after its selected step. + let record_params = match selected.and_then(|(s, a)| Some((s, a, app.ui.action_sets.get(s)?.actions.get(a)?.id))) { + Some((s, a, id)) => match step_selected.filter(|&(step_id, _)| step_id == id) { + Some((_, step)) => json!({"set": s, "action": a, "after": step}), + None => json!({"set": s, "action": a}), + }, + None => json!({}), + }; if app.ui.action_sets.is_empty() { dim_label(ui, tl!("No actions. Press ● to record.")); } @@ -2107,7 +2399,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ui.painter().circle_filled(r.center(), 6.0, if recording { egui::Color32::from_rgb(0xe0, 0x30, 0x30) } else { t.icon }); if resp.on_hover_text(tl!("Begin Recording")).clicked() && !recording - && let Err(e) = app.run(START_RECORDING, json!({})) + && let Err(e) = app.run(START_RECORDING, record_params.clone()) { app.status(e); } @@ -5261,6 +5553,212 @@ mod tests { assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); } + /// A script that moves the selection twice. + const TWO_MOVES: &str = "vc.run('object.move', {dx: 1, dy: 0}); vc.run('object.move', {dx: 1, dy: 0});"; + + /// Runs `source` to its end: in one call (`frames` false, a run with nobody to ask), or a frame + /// at a time, as the app does. + fn run_script(app: &mut VectorcraftApp, source: &str, frames: bool) { + app.session.script_frames = frames; + app.run("script.run", json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if !app.session.script_running() { + return; + } + crate::script_runs::poll(app); + } + panic!("the script did not end"); + } + + /// Begin Recording with `filler` steps already recorded. + fn recording_with(app: &mut VectorcraftApp, filler: usize) { + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 0..filler { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + } + + /// A script whose commands take a recording past 10,000 steps keeps its edit and the whole + /// journal, the status bar names the limit as the run ends, the next recorded command does not + /// run, and Stop Playing/Recording keeps the 10,000 steps that fit. One that fills the action + /// exactly names no limit. + #[test] + fn script_run_checks_recording_step_limit_on_completion() { + let steps = "the action already has 10,000 steps"; + for frames in [false, true] { + let (mut app, x) = app_with_three_moves(); + recording_with(&mut app, files::MAX_ACTION_STEPS - 1); + app.ui.status.clear(); + run_script(&mut app, TWO_MOVES, frames); + assert_eq!(x(&app), 2.0, "frames {frames}: the script's edit stays"); + let start = recording_start(&app).unwrap(); + assert_eq!(app.session.journal.len() - start, files::MAX_ACTION_STEPS + 1, "frames {frames}: the journal keeps every step"); + assert_eq!(app.ui.status, steps, "frames {frames}: the status bar names the limit now"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + assert_eq!(app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap_err(), steps, "no later command runs"); + assert_eq!(x(&app), 2.0); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps.len(), files::MAX_ACTION_STEPS); + assert_eq!(recorded.steps.last().map(|(c, _)| c.as_str()), Some("object.move"), "the first move fits"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[1].steps.len(), files::MAX_ACTION_STEPS, "and loads again"); + + let (mut app, _) = app_with_three_moves(); + recording_with(&mut app, files::MAX_ACTION_STEPS - 2); + app.ui.status.clear(); + run_script(&mut app, TWO_MOVES, frames); + let ran = if frames { "Ran t.js" } else { "" }; + assert_eq!(app.ui.status, ran, "frames {frames}: 10,000 steps fit"); + } + } + + /// A script whose commands give the recording's set a file larger than 8 MiB keeps its edit, + /// the status bar names the limit as the run ends, and Stop Playing/Recording keeps the steps + /// that fit in a set that Load Actions… reads. One that fills the file exactly names no limit. + #[test] + fn script_run_checks_recording_byte_limit_on_completion() { + let size = "the action set would be larger than 8 MiB"; + for frames in [false, true] { + for spare in [0, 1] { + let (mut app, x) = app_with_three_moves(); + // The two steps as an action keeps them. + let before = app.session.journal.len(); + run_script(&mut app, TWO_MOVES, false); + let both: usize = app.session.journal_for_action(before).iter().map(|(c, p)| files::step_bytes(c, p)).sum(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 0.0); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + // Room for both moves, or for one move and all but a byte of the other. + app.ui.action_sets[0].actions[0].steps = vec![pad(room - both + 1 - spare)]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + app.ui.status.clear(); + run_script(&mut app, TWO_MOVES, frames); + assert_eq!(x(&app), 2.0, "frames {frames}: the script's edit stays"); + let what = format!("frames {frames}, spare {spare}"); + if spare == 1 { + assert_eq!(app.ui.status, if frames { "Ran t.js" } else { "" }, "{what}: the file is exactly 8 MiB"); + continue; + } + assert_eq!(app.ui.status, size, "{what}: the status bar names the limit now"); + assert_eq!(app.session.journal.since(recording_start(&app).unwrap()).len(), 2, "the journal keeps both moves"); + assert_eq!(app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap_err(), size, "no later command runs"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 2, "{what}: the first move fits"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert!(saved.len() <= files::MAX_SET_FILE); + assert!(files::read_set(&saved, "Test").is_ok()); + } + } + } + + /// Presses, drags and releases the active tool from `from` to `to`, as the canvas hands its + /// results on ([`crate::canvas::apply_requests`]). + fn tool_drag(app: &mut VectorcraftApp, from: (f64, f64), to: (f64, f64)) { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let view = app.view_info(); + for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + let r = app.session.pointer(&PointerEvent::new(kind, x, y), view); + crate::canvas::apply_requests(app, r); + } + } + + /// A drag with the Selection tool (a committed preview) or the Rectangle tool (a command the + /// tool runs) that takes a recording past 10,000 steps keeps its edit and the journal, the + /// status bar names the limit as the tool ends, and Stop Playing/Recording keeps the 10,000 + /// steps that fit. One that fills the action exactly names no limit. + #[test] + fn tool_commit_checks_recording_step_limit() { + let steps = "the action already has 10,000 steps"; + for tool in ["selection", "rectangle"] { + for filler in [files::MAX_ACTION_STEPS, files::MAX_ACTION_STEPS - 1] { + let (mut app, _) = app_with_three_moves(); + app.select_tool(tool); + recording_with(&mut app, filler); + app.ui.status.clear(); + let (journal, art) = (app.session.journal.len(), app.session.active().unwrap().doc.art_bounds()); + tool_drag(&mut app, (20.0, 20.0), (60.0, 50.0)); + assert_eq!(app.session.journal.len(), journal + 1, "{tool}: the tool journals one step"); + assert_ne!(app.session.active().unwrap().doc.art_bounds(), art, "{tool}: the edit stays"); + if filler < files::MAX_ACTION_STEPS { + assert_eq!(app.ui.status, "", "{tool}: 10,000 steps fit"); + continue; + } + assert_eq!(app.ui.status, steps, "{tool}: the status bar names the limit now"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS); + } + } + } + + /// A tool's edit whose step gives the recording's set a file larger than 8 MiB keeps its edit, + /// the status bar names the limit as the tool ends, and Stop Playing/Recording leaves the step + /// out of a set that Load Actions… reads. + #[test] + fn tool_commit_checks_recording_byte_limit() { + let size = "the action set would be larger than 8 MiB"; + for tool in ["selection", "rectangle"] { + let (mut app, _) = app_with_three_moves(); + app.select_tool(tool); + app.run(START_RECORDING, json!({})).unwrap(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = vec![pad(room)]; + app.ui.recording_into = Some(app.ui.action_sets[0].actions[0].id); + app.ui.status.clear(); + let art = app.session.active().unwrap().doc.art_bounds(); + tool_drag(&mut app, (20.0, 20.0), (60.0, 50.0)); + assert_ne!(app.session.active().unwrap().doc.art_bounds(), art, "{tool}: the edit stays"); + assert_eq!(app.ui.status, size, "{tool}: the status bar names the limit now"); + assert_eq!(app.session.journal.since(recording_start(&app).unwrap()).len(), 1, "{tool}: the journal keeps the step"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "{tool}: the step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + } + + /// A step that reaches the journal outside a command, as a panel's or a dialog's commit does, is + /// checked on the app's next frame. A journal rewrite that makes a checked step larger is + /// checked again, and a frame with no new step does not name the limit again. + #[test] + fn a_journal_rewrite_or_a_direct_commit_is_checked_on_the_next_frame() { + let size = "the action set would be larger than 8 MiB"; + let (mut app, _) = app_with_three_moves(); + let small = ("object.move".to_string(), json!({"dx": 1, "dy": 0})); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.ui.action_sets[0].actions[0].steps = vec![pad(room - files::step_bytes(&small.0, &small.1))]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp| { + app.begin_host_iteration(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.tick(ui.ctx())); + out.textures_delta.clear(); + }; + app.session.journal.push(small); + app.ui.status.clear(); + frame(&mut app); + assert_eq!(app.ui.status, "", "a step that fills the file exactly"); + let start = recording_start(&app).unwrap(); + app.session.journal.replace(start, pad(1)); + frame(&mut app); + assert_eq!(app.ui.status, size, "the rewritten step no longer fits"); + app.ui.status.clear(); + frame(&mut app); + assert_eq!(app.ui.status, "", "named once"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "the step is left out"); + } + /// When the action New Action made for the recording in progress is deleted, Stop /// Playing/Recording keeps none of its steps, so no limit stops a recorded command, even with /// the set's file at 8 MiB. @@ -5642,6 +6140,350 @@ mod tests { assert_eq!(open_kind(&app), None, "deleting its step closes it"); } + /// The steps of action `action` in set `set`: each command, with its `dx` when it has one. + fn step_names(app: &VectorcraftApp, set: usize, action: usize) -> Vec { + app.ui.action_sets[set].actions[action] + .steps + .iter() + .map(|(c, p)| match p.get("dx") { + Some(dx) => format!("{c} {dx}"), + None => c.clone(), + }) + .collect() + } + + /// The bottom bar's Stop and Begin Recording buttons, as drawn last. + fn stop_and_record(ctx: &egui::Context, app: &mut VectorcraftApp) -> (egui::Pos2, egui::Pos2) { + super::super::tests_appearance::frame_events(ctx, app, vec![], show); + let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0)); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [stop, record, ..] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + (stop.center(), record.center()) + } + + #[test] + fn begin_recording_selected_action_adds_steps_without_new_action() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show); + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no new action"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4", "object.move 8"], "added at the end"); + + // With a step selected, the recorded steps go after it. + click_first_step(&ctx, &mut app); + // The steps shown move the bottom bar down. + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 16", "object.move 2", "object.move 4", "object.move 8"]); + + // With no action selected, Begin Recording still adds a new action. + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "▾ Moves").center(), show); + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 32, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + let names: Vec<&str> = app.ui.action_sets[0].actions.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["Three Moves", "Action 2"]); + assert_eq!(step_names(&app, 0, 1), ["object.move 32"]); + } + + #[test] + fn begin_recording_after_selected_step_preserves_order_and_exclusions() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + for (params, want) in [ + (json!({"action": 0}), "no such action"), + (json!({"set": 0, "after": 0}), "no such action"), + (json!({"set": 1, "action": 0}), "no such action"), + (json!({"set": 0, "action": 1}), "no such action"), + (json!({"set": 0, "action": "0"}), "no such action"), + (json!({"set": 0, "action": 0, "after": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "after": null}), "no such action step"), + (json!({"set": 0, "action": 0, "after": -1}), "no such action step"), + ] { + assert_eq!(app.run(START_RECORDING, params.clone()).unwrap_err(), want, "{params}"); + assert!(app.ui.recording.is_none(), "{params}"); + } + assert_eq!( + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 0})).unwrap(), + json!({"set": 0, "action": 0, "name": "Three Moves"}) + ); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no new action"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 8", "object.move 16", "object.move 2", "object.move 4"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([0, 4]), "the steps keep whether they are excluded"); + } + + #[test] + fn steps_recorded_into_the_middle_of_an_action_keep_the_selected_step_and_dialog_on_their_steps() { + let (mut app, _) = app_with_three_moves(); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let id = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 0})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 2))); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(1)); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 8", "object.move 16", "object.move 2", "object.move 4"]); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((id, 4)), "the move by 4 stays selected"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(3), "Record Stop stays after the move by 2"); + } + + #[test] + fn step_edits_while_recording_into_an_action_keep_the_recording_in_place() { + let (mut app, _) = app_with_three_moves(); + // The recording goes between the moves by 2 and 4. + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 1})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "before": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 4", "object.move 2", STOP_STEP, "object.move 8"]); + + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 1})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 8", "object.move 4"], "a step deleted before the place"); + } + + #[test] + fn move_step_within_action_updates_order_exclusions_and_selection() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].steps.push(("object.move".into(), json!({"dx": 8, "dy": 0}))); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1]); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let id = app.ui.action_sets[0].actions[0].id; + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 3))); + let selected_step = |ctx: &egui::Context| pstate::>(ctx, SELECTED_STEP); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": 4})).unwrap(), + json!({"set": 0, "action": 0, "step": 3, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 4", "object.move 8", "object.move 2"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([3]), "the moved step stays excluded"); + assert_eq!(selected_step(&ctx), Some((id, 2)), "the selected step stays selected"); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "before": 0})).unwrap(), + json!({"set": 0, "action": 0, "step": 0, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 8", "object.move 1", "object.move 4", "object.move 2"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([3])); + assert_eq!(selected_step(&ctx), Some((id, 0)), "the moved selected step is selected where it went"); + for before in [1, 2] { + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": before})).unwrap(), + json!({"set": 0, "action": 0, "step": 1, "moved": false}) + ); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 8", "object.move 1", "object.move 4", "object.move 2"], "a drop on itself moves nothing"); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(3)); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 3, "before": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(0), "an open Record Stop follows its step"); + } + + #[test] + fn move_step_between_actions_and_sets_keeps_file_roundtrip() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([2]); + let rotate = ("object.rotate".to_string(), json!({"angle": 5})); + app.ui.action_sets.push(ActionSet::new("Other", vec![Action::new("Empty", vec![]), Action::new("One", vec![rotate])])); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let (id, empty) = (app.ui.action_sets[0].actions[0].id, app.ui.action_sets[1].actions[0].id); + set_pstate::>(&ctx, "actions-selected", Some((0, 0))); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 2))); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "toSet": 1, "toAction": 0, "before": 0})).unwrap(), + json!({"set": 1, "action": 0, "step": 0, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2"]); + assert!(app.ui.action_sets[0].actions[0].excluded_steps.is_empty()); + assert_eq!(step_names(&app, 1, 0), ["object.move 4"], "an empty action takes a step"); + assert_eq!(app.ui.action_sets[1].actions[0].excluded_steps, BTreeSet::from([0]), "the moved step stays excluded"); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((empty, 0))); + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((1, 0))); + assert_eq!((app.ui.action_set, app.ui.action_sets_selected.as_slice()), (Some(1), &[1][..])); + assert!(pstate::>(&ctx, "actions-expanded").contains(&empty), "the step's new action shows its steps"); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 1, "before": 0})).unwrap(); + assert_eq!(step_names(&app, 1, 1), ["object.move 1", "object.rotate"]); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((empty, 0)), "an unmoved selected step stays"); + let loaded = files::read_set(&files::write_set(&app.ui.action_sets[1]), "Other").unwrap(); + for (a, b) in loaded.actions.iter().zip(&app.ui.action_sets[1].actions) { + assert_eq!((&a.name, &a.steps, &a.excluded_steps), (&b.name, &b.steps, &b.excluded_steps)); + } + } + + #[test] + fn move_step_rejects_invalid_and_oversize_destinations_without_changes() { + let (mut app, _) = app_with_three_moves(); + let full = Action::new("Full", vec![("object.move".to_string(), json!({"dx": 0})); files::MAX_ACTION_STEPS]); + let big = |text: String| ActionSet::new("Big", vec![Action::new("Big", vec![("document.note".to_string(), json!({ "s": text }))])]); + let room = files::MAX_SET_FILE - files::write_set(&big(String::new())).len() - 4; + app.ui.action_sets.push(ActionSet::new("Full", vec![full])); + app.ui.action_sets.push(big("x".repeat(room))); + assert!(files::write_set(&app.ui.action_sets[2]).len() <= files::MAX_SET_FILE); + let before: Vec = app.ui.action_sets.iter().map(files::write_set).collect(); + for (params, want) in [ + (json!({}), "no such action"), + (json!({"set": 0, "action": 1, "step": 0, "before": 0}), "no such action"), + (json!({"set": 3, "action": 0, "step": 0, "before": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 3, "before": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "before": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "before": 4}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "before": "1"}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 3, "before": 0}), "no such action set"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": null, "before": 0}), "no such action set"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 1, "before": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 0, "before": 0}), "the action already has 10,000 steps"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 2, "toAction": 0, "before": 1}), "the action set would be larger than 8 MiB"), + ] { + assert_eq!(app.run(MOVE_STEP, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets.iter().map(files::write_set).collect::>(), before, "{params}"); + } + } + + #[test] + fn drag_step_shows_insertion_line_and_moves_once() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let (from, to) = (rows[0].center(), egui::pos2(rows[0].center().x, rows[2].center().y + 4.0)); + let accent = Tokens::get(&ctx).accent; + // Whether a frame with `events` draws the insertion line. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + fn line(s: &egui::Shape, accent: egui::Color32) -> bool { + match s { + egui::Shape::LineSegment { stroke, .. } => stroke.width == 2.0 && stroke.color == accent, + egui::Shape::Vec(v) => v.iter().any(|s| line(s, accent)), + _ => false, + } + } + out.shapes.iter().any(|c| line(&c.shape, accent)) + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(!frame(&mut app, vec![Event::PointerMoved(from)]), "no line before a drag"); + frame(&mut app, vec![button(from, true)]); + frame(&mut app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + assert!(frame(&mut app, vec![Event::PointerMoved(to)]), "the line shows where the step goes"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4"], "nothing moves while dragging"); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 4", "object.move 1"], "the release moves the step"); + for _ in 0..3 { + assert!(!frame(&mut app, vec![]), "no line after the drop"); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 4", "object.move 1"], "the step moves once"); + } + + #[test] + fn drag_step_released_off_the_rows_leaves_it_in_place_without_an_undo_step() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let from = rows[0].center(); + let over = egui::pos2(from.x, rows[2].center().y + 4.0); + let off = egui::pos2(from.x, rows[2].bottom() + 2_000.0); + let undo = undo_steps(&app); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![button(from, true)]); + frame(&mut app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + frame(&mut app, vec![Event::PointerMoved(over)]); + frame(&mut app, vec![Event::PointerMoved(off)]); + frame(&mut app, vec![button(off, false)]); + for _ in 0..3 { + frame(&mut app, vec![]); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4"], "the step stays where it was"); + assert_eq!(undo_steps(&app), undo, "no undo step"); + assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + } + + #[test] + fn drag_step_ends_without_a_move_when_a_command_changes_its_action() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + let rotate = |angle: u32| ("object.rotate".to_string(), json!({"angle": angle})); + app.ui.action_sets[0].actions.push(Action::new("Turns", vec![rotate(5), rotate(6)])); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let (from, to) = (rows[0].center(), egui::pos2(rows[0].center().x, rows[1].center().y + 4.0)); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let start_drag = |app: &mut VectorcraftApp| { + frame(app, vec![button(from, true)]); + frame(app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + }; + // A command on another action leaves the drag as it is. + start_drag(&mut app); + app.run(DELETE_STEP, json!({"set": 0, "action": 1, "step": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 1", "object.move 4"], "the dragged step moves"); + // A command that deletes the dragged step's action's first step puts another step at the + // dragged index. + start_drag(&mut app); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 4"], "no other step moves"); + // Two equal steps, one excluded, that change places put the other one at the dragged index. + let same = ("object.move".to_string(), json!({"dx": 1, "dy": 0})); + app.ui.action_sets[0].actions[0].steps = vec![same.clone(), same]; + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1]); + start_drag(&mut app); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + frame(&mut app, vec![button(to, false)]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([0]), "the excluded step stays first"); + } + #[test] fn step_and_set_edits_keep_an_open_insert_menu_item_on_its_place() { let (mut app, _) = app_with_three_moves(); diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 8f29c70475..82991005ab 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -637,10 +637,16 @@ impl LibraryKind for BrushLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.brush_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { - let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); + /// Find matches a brush's name as shown and as stored. + fn rows<'a>(lib: &'a Self::Lib, id: &str, query: &str) -> Vec> { + let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query) || Self::label(id, n).to_lowercase().contains(query); lib.iter().filter(|b| found(b.name())).map(|b| Row { name: b.name(), item: Some(b) }).collect() } + /// A built-in library's brush in the UI language; a User Defined or loaded library's brush as + /// it is named. + fn label<'a>(id: &str, name: &'a str) -> &'a str { + super::label_or_name(name, brushlib::BUILTIN.iter().any(|b| b.id == id)) + } /// The brush's stroke preview, drawn with the library's resources. fn draw(_: &VectorcraftApp, ui: &Ui, r: egui::Rect, b: &LibBrush, selected: bool, hovered: bool) { chip(ui, r, &b.lib.doc, &b.shown.def, b.shown.key); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index de42febef6..c7a3e818cd 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -42,14 +42,21 @@ pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft /// The first selected text descendant, shared by Character and Paragraph controls. pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> { + match &first_selected_text_node(app)?.kind { + NodeKind::Text(t) => Some(t.as_ref()), + _ => None, + } +} + +/// The node of [`first_selected_text`]: the first text object among the selected objects and +/// their descendants, in selection order, then paint order within each selected object. +pub(crate) fn first_selected_text_node(app: &VectorcraftApp) -> Option<&vectorcraft_doc::Node> { let st = app.session.active()?; for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { let mut text = None; node.walk(&mut |n| { - if text.is_none() - && let NodeKind::Text(t) = &n.kind - { - text = Some(t.as_ref()); + if text.is_none() && matches!(n.kind, NodeKind::Text(_)) { + text = Some(n); } }); if text.is_some() { @@ -922,8 +929,13 @@ fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { + size_field_named(app, ui, id, None, width); +} + +/// [`size_field`] named `name` for screen readers, where no label stands beside it. +fn size_field_named(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, name: Option<&str>, width: f32) { let size = shared_effective(app, |s| s.size); - if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { + if let Some(v) = widgets::spin_field_named(ui, id, name, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { style(app, json!({"size": v})); } } @@ -940,18 +952,92 @@ const FONT_HEIGHT_REFERENCES: [(vectorcraft_doc::FontHeightReference, &str); 4] (vectorcraft_doc::FontHeightReference::IcfBox, "icfBox"), ]; +/// The size of the icon before each name in the Set Font Height Reference dropdown. +const FONT_HEIGHT_ICON: f32 = 16.0; + /// The Set Font Height Reference dropdown, `width` wide: the height of the font that the Font Size -/// measures, blank where the characters the panel acts on differ. +/// measures, blank where the characters the panel acts on differ. Each reference shows an icon of +/// the height it measures before its name ([`paint_font_height_icon`], #6304). fn font_height_reference(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { // The labels of [`FONT_HEIGHT_REFERENCES`], in its order. let labels = [tl!("Em Box"), tl!("Cap Height"), tl!("x-Height"), tl!("ICF Box")]; let current = shared(app, |s| s.font_height_reference); - let shown = FONT_HEIGHT_REFERENCES.iter().zip(labels).find(|(r, _)| Some(r.0) == current).map_or("", |(_, l)| l); - if let Some((_, key)) = widgets::dropdown_names(ui, "ch-font-height", shown, &labels, width).and_then(|i| FONT_HEIGHT_REFERENCES.get(i)) { + let shown = FONT_HEIGHT_REFERENCES.iter().position(|(r, _)| Some(*r) == current); + let accent = Tokens::get(ui.ctx()).accent; + let icon = |ui: &Ui, rect: egui::Rect, i: usize, ink: egui::Color32, highlighted: bool| { + // On the highlighted row the selection fill is accent-tinted, so the measure takes the + // name's colour there to keep its contrast. + let mark = if highlighted { ink } else { accent }; + if let Some((r, _)) = FONT_HEIGHT_REFERENCES.get(i) { + paint_font_height_icon(ui.painter(), rect, *r, ink, mark); + } + }; + let picked = widgets::dropdown_icon_names(ui, "ch-font-height", shown, &labels, width, FONT_HEIGHT_ICON, icon); + if let Some((_, key)) = picked.and_then(|i| FONT_HEIGHT_REFERENCES.get(i)) { format(app, json!({"fontHeightReference": key})); } } +/// Paints the icon of font height reference `r` into the square `rect` (#6304): a letter drawn in +/// `ink` on a baseline, and in `mark` the height the reference measures. Em Box frames the whole +/// square around an "A"; Cap Height puts a bar from the baseline to the top of a capital "A" +/// beside it; x-Height a bar from the baseline to the top of a lowercase "x"; ICF Box frames a +/// square inset in the faint em square around an ideograph-like cross, as the ideographic +/// character face sits inside the em box. The drawings are VectorCraft's own. +fn paint_font_height_icon( + painter: &egui::Painter, + rect: egui::Rect, + r: vectorcraft_doc::FontHeightReference, + ink: egui::Color32, + mark: egui::Color32, +) { + use vectorcraft_doc::FontHeightReference as R; + // A point at (x, y) in the unit square mapped onto `rect`. + let p = |x: f32, y: f32| rect.lerp_inside(vec2(x, y)); + let glyph = egui::Stroke::new(1.3, ink); + let measure = egui::Stroke::new(1.2, mark); + let line = |a: (f32, f32), b: (f32, f32), stroke: egui::Stroke| painter.line_segment([p(a.0, a.1), p(b.0, b.1)], stroke); + let frame = |min: f32, max: f32, stroke: egui::Stroke| { + painter.rect_stroke(egui::Rect::from_min_max(p(min, min), p(max, max)), 0.0, stroke, egui::StrokeKind::Middle); + }; + // A capital A from `left` to `right`, its apex at `top` and its feet on `base`. + let capital = |left: f32, right: f32, top: f32, base: f32| { + let mid = (left + right) / 2.0; + painter.add(egui::Shape::line(vec![p(left, base), p(mid, top), p(right, base)], glyph)); + let bar = top + (base - top) * 0.62; + let inset = (right - left) * 0.5 * 0.62; + line((mid - inset, bar), (mid + inset, bar), glyph); + }; + // A vertical measure from `top` to `base` at `x`, with ticks at its ends. + let height_bar = |x: f32, top: f32, base: f32| { + line((x, top), (x, base), measure); + line((x - 0.1, top), (x + 0.1, top), measure); + line((x - 0.1, base), (x + 0.1, base), measure); + }; + let (base, cap_top, x_top) = (0.86, 0.16, 0.46); + match r { + R::EmBox => { + frame(0.06, 0.94, measure); + capital(0.26, 0.74, 0.26, 0.76); + } + R::CapHeight => { + capital(0.06, 0.6, cap_top, base); + height_bar(0.82, cap_top, base); + } + R::XHeight => { + line((0.1, x_top), (0.54, base), glyph); + line((0.54, x_top), (0.1, base), glyph); + height_bar(0.82, x_top, base); + } + R::IcfBox => { + frame(0.06, 0.94, egui::Stroke::new(1.0, ink.gamma_multiply(0.4))); + frame(0.2, 0.8, measure); + line((0.33, 0.5), (0.67, 0.5), glyph); + line((0.5, 0.31), (0.5, 0.69), glyph); + } + } +} + /// The font family menu and the font style dropdown of style `s` (Character and Properties panels, /// Control bar), with their `ids` and `widths`. The mouse wheel over the closed family field steps /// the text through the families. @@ -962,7 +1048,7 @@ pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, "cb-font" => Some(crate::font_menu::WheelOwner::Control), _ => None, }; - font_pickers_owned(app, ui, s, ids, widths, wheel_owner); + font_pickers_owned(app, ui, s, ids, widths, wheel_owner, (None, None)); } fn font_pickers_owned( @@ -972,16 +1058,17 @@ fn font_pickers_owned( ids: (&str, &str), widths: (f32, f32), wheel_owner: Option, + a11y: (Option<&str>, Option<&str>), ) { crate::font_menu::sync_wheel_target(app, ui.ctx(), ui.ctx().viewport_id()); let sample = crate::font_menu::sample_text(app); - let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, ..crate::font_menu::MenuLook::of(app) }; + let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, name: a11y.0, ..crate::font_menu::MenuLook::of(app) }; if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), look) { crate::font_menu::apply(app, ui.ctx(), pick); } let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); let names: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(name) = widgets::dropdown_names(ui, ids.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { + if let Some(name) = widgets::dropdown_names_named(ui, ids.1, a11y.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { style(app, json!({"style": name})); } } @@ -1076,6 +1163,35 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { crate::chrome::group_separator(ui); } +/// The Contextual Task Bar's widths of the font family menu, the font style dropdown and the +/// Font Size combo. +pub(crate) const TASK_BAR_WIDTHS: [f32; 3] = [140.0, 90.0, 76.0]; + +/// The smallest share of [`TASK_BAR_WIDTHS`] the Contextual Task Bar's type controls narrow to. +const TASK_BAR_MIN_SCALE: f32 = 0.4; + +/// The gaps between the Contextual Task Bar's type controls. +fn task_bar_gaps() -> f32 { + 6.0 * (TASK_BAR_WIDTHS.len() - 1) as f32 +} + +/// The narrowest room the Contextual Task Bar's type controls fit in, with their spacing. +pub(crate) fn task_bar_min_room() -> f32 { + TASK_BAR_MIN_SCALE * TASK_BAR_WIDTHS.iter().sum::() + task_bar_gaps() +} + +/// The Contextual Task Bar's type controls while type is selected or edited: the font family and +/// style menus and the Font Size combo, as the Control bar has them, named for screen readers. +/// They narrow, down to 40% of their widths, to fit in `room` points with their spacing. +pub(crate) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, room: f32) { + let Some((s, _)) = text_style(app) else { return }; + let k = ((room - task_bar_gaps()) / TASK_BAR_WIDTHS.iter().sum::()).clamp(TASK_BAR_MIN_SCALE, 1.0); + let [family, style, size] = TASK_BAR_WIDTHS.map(|w| w * k); + let owner = Some(crate::font_menu::WheelOwner::TaskBar); + font_pickers_owned(app, ui, &s, ("tb-font", "tb-font-style"), (family, style), owner, (Some(tl!("Font Family")), Some(tl!("Style")))); + size_field_named(app, ui, "tb-font-size", Some(tl!("Font Size")), size); +} + /// The panel state key of the panel menu's Show Snap to Glyph Options. const SNAP_TO_GLYPH_OPTIONS: &str = "ch-snap-to-glyph-options"; /// Set when Show Snap to Glyph Options is chosen: the next frame scrolls the panel to the options, @@ -1146,7 +1262,7 @@ fn attributes(app: &mut VectorcraftApp, ui: &mut Ui, owner: crate::font_menu::Wh if let Some(f) = app.character_focus { widgets::arm_field_focus(ui.ctx(), f.salt()); } - font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner)); + font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner), (None, None)); variable_font(app, ui, &s, w - 4.0); ui.add_space(2.0); let hidden = pstate::(ui.ctx(), "ch-hide-options"); diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index a90286e2fb..dd4b12adf5 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -822,6 +822,135 @@ fn show_font_height_options_shows_the_font_height_reference() { assert!(item(&texts(&ctx, &mut app, show), "Cap Height").is_none()); } +/// The Set Font Height Reference dropdown shows an icon before each reference's name, in the +/// closed field and in the open list, and each reference's icon is its own (#6304). +#[test] +fn font_height_references_show_their_icons() { + use super::super::tests_appearance::click; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 12})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, FONT_HEIGHT_OPTIONS, true); + // Every text drawn with its rect, and the rects of every other shape drawn. + let frame = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, texts: &mut Vec<(String, egui::Rect)>, marks: &mut Vec) { + match s { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, texts, marks)), + egui::Shape::Noop => {} + s => marks.push(s.visual_bounding_rect()), + } + } + let (mut texts, mut marks) = (vec![], vec![]); + for c in &out.shapes { + collect(&c.shape, &mut texts, &mut marks); + } + (texts, marks) + }; + // The shapes in the icon's place before the text `rect`, relative to that place and rounded. + let icon = |marks: &[egui::Rect], rect: egui::Rect| { + let place = egui::Rect::from_min_max( + egui::pos2(rect.min.x - FONT_HEIGHT_ICON - 8.0, rect.center().y - 10.0), + egui::pos2(rect.min.x, rect.center().y + 10.0), + ); + let mut found: Vec<[i32; 4]> = marks + .iter() + .filter(|m| m.is_finite() && place.contains_rect(**m)) + .map(|m| [m.min.x - place.min.x, m.min.y - place.min.y, m.max.x - place.min.x, m.max.y - place.min.y].map(|v| v.round() as i32)) + .collect(); + found.sort_unstable(); + found + }; + frame(&ctx, &mut app); + let (texts, marks) = frame(&ctx, &mut app); + let field = texts.iter().find(|(t, _)| t == "Em Box").map(|(_, r)| *r).expect("the closed field shows Em Box"); + assert!(icon(&marks, field).len() >= 2, "no icon before Em Box in the closed field: {:?}", icon(&marks, field)); + click(&ctx, &mut app, field.center(), show); + frame(&ctx, &mut app); + let (texts, marks) = frame(&ctx, &mut app); + let mut icons = vec![]; + for name in ["Em Box", "Cap Height", "x-Height", "ICF Box"] { + // The list's row is the last text of that name drawn (the closed field is drawn first). + let row = texts.iter().rev().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("the open list shows {name}: {texts:?}")); + let found = icon(&marks, row); + assert!(found.len() >= 2, "no icon before {name} in the open list: {found:?}"); + icons.push(found); + } + for (i, a) in icons.iter().enumerate() { + assert!(icons.iter().skip(i + 1).all(|b| a != b), "two references share an icon: {icons:?}"); + } +} + +/// In the open Set Font Height Reference list, the current reference's row is drawn on the +/// accent-tinted selection fill, so its icon draws the measure in the name's colour rather than +/// the accent, which would lose its contrast there; the other rows keep the accent (#6304). +#[test] +fn the_highlighted_font_height_icon_keeps_its_contrast() { + use super::super::tests_appearance::click; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 12})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, FONT_HEIGHT_OPTIONS, true); + let accent = Tokens::get(&ctx).accent; + // Every text drawn with its rect, and every stroke drawn with its rect and colour. + let frame = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, texts: &mut Vec<(String, egui::Rect)>, strokes: &mut Vec<(egui::Rect, egui::Color32)>) { + match s { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, texts, strokes)), + egui::Shape::LineSegment { stroke, .. } => strokes.push((s.visual_bounding_rect(), stroke.color)), + egui::Shape::Path(p) => { + if let egui::epaint::ColorMode::Solid(c) = p.stroke.color { + strokes.push((s.visual_bounding_rect(), c)); + } + } + egui::Shape::Rect(r) => strokes.push((s.visual_bounding_rect(), r.stroke.color)), + _ => {} + } + } + let (mut texts, mut strokes) = (vec![], vec![]); + for c in &out.shapes { + collect(&c.shape, &mut texts, &mut strokes); + } + (texts, strokes) + }; + // The stroke colours in the icon's place before the text `rect`. + let colours = |strokes: &[(egui::Rect, egui::Color32)], rect: egui::Rect| -> Vec { + let place = egui::Rect::from_min_max( + egui::pos2(rect.min.x - FONT_HEIGHT_ICON - 8.0, rect.center().y - 10.0), + egui::pos2(rect.min.x, rect.center().y + 10.0), + ); + strokes.iter().filter(|(r, _)| r.is_finite() && place.contains_rect(*r)).map(|(_, c)| *c).collect() + }; + frame(&ctx, &mut app); + let (texts, _) = frame(&ctx, &mut app); + let field = texts.iter().find(|(t, _)| t == "Em Box").map(|(_, r)| *r).expect("the closed field shows Em Box"); + click(&ctx, &mut app, field.center(), show); + frame(&ctx, &mut app); + let (texts, strokes) = frame(&ctx, &mut app); + let row = |name: &str| texts.iter().rev().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("the open list shows {name}")); + // The list fades in, so colours are compared without their alpha. + let rgb = |c: egui::Color32| { + let [r, g, b, _] = c.to_srgba_unmultiplied(); + [r, g, b] + }; + let is_accent = |c: &egui::Color32| rgb(*c).iter().zip(rgb(accent)).all(|(a, b)| a.abs_diff(b) <= 4); + let current = colours(&strokes, row("Em Box")); + assert!(!current.is_empty() && !current.iter().any(is_accent), "the highlighted Em Box icon draws in the accent: {current:?}"); + let other = colours(&strokes, row("Cap Height")); + assert!(other.iter().any(is_accent), "the other rows keep the accent measure: {other:?}"); +} + /// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the /// selected characters. #[test] diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index a28fba02e5..0ad9c369fd 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -248,8 +248,9 @@ pub(crate) fn warning_chip(ui: &mut Ui, (icon, warning): (&str, &str), fix: &Col .inner } -/// The printable colour closest to `c` (its working-CMYK reproduction, back in RGB) when `c` is out -/// of the CMYK gamut, through `color.convert`. +/// The printable color closest to `c` (its reproduction in the active CMYK space, the document's +/// assigned CMYK profile or else the working CMYK space, back in RGB) when `c` is out of that +/// space's gamut, through `color.convert`. pub(crate) fn in_gamut(app: &mut VectorcraftApp, c: &Color) -> Option { let r = app.run("color.convert", json!({"color": color_json(c), "to": "cmyk"})).ok()?; if !r["outOfGamut"].as_bool()? { @@ -261,18 +262,25 @@ pub(crate) fn in_gamut(app: &mut VectorcraftApp, c: &Color) -> Option { Some(Color::rgb(v(&rgb, 0)? as f32, v(&rgb, 1)? as f32, v(&rgb, 2)? as f32)) } -/// [`in_gamut`], cached under `key` for the last colour asked (panels ask every frame). +/// [`in_gamut`], cached under `key` for the last color asked (panels ask every frame) together with +/// the active color settings and profiles generation it was asked under, so a change of Color +/// Settings or of the document's assigned profiles updates the warning while the color stays the same. pub(crate) fn gamut_fix(app: &mut VectorcraftApp, ctx: &egui::Context, key: &str, c: &Color) -> Option { - match pstate::)>>(ctx, key) { - Some((k, fix)) if k == *c => fix, + let under = (cms::active_settings(), cms::profiles_generation()); + match pstate::>(ctx, key) { + Some((k, at, fix)) if k == *c && at == under => fix, _ => { let fix = in_gamut(app, c); - set_pstate(ctx, key, Some((*c, fix))); + set_pstate(ctx, key, Some((*c, under, fix))); fix } } } +/// A [`gamut_fix`] cache entry: the color, the active color settings and profiles generation it was +/// asked under, and its correction. +pub(crate) type GamutFix = (Color, (cms::ColorSettings, u64), Option); + /// Serde reader for the saved panel mode ([`crate::UiState::color_mode`]): a menu mode, else none, /// so an unknown value doesn't lose the rest of the preferences. pub fn deserialize_mode<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index 4538a8e5ce..cd7b88cdd0 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -349,12 +349,12 @@ fn pure_blue_is_out_of_gamut_in_rgb() { let ctx = egui::Context::default(); app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - let (of, fix) = pstate::)>>(&ctx, "color-gamut").expect("RGB mode checks the gamut"); + let (of, _, fix) = pstate::>(&ctx, "color-gamut").expect("RGB mode checks the gamut"); assert_eq!(of.to_hex(), "#0000ff"); assert!(fix.is_some_and(|f| !f.out_of_gamut())); app.run("paint.setFill", json!({"color": "#808080"})).unwrap(); frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); - assert_eq!(pstate::)>>(&ctx, "color-gamut").unwrap().1, None); + assert_eq!(pstate::>(&ctx, "color-gamut").unwrap().2, None); } #[test] diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 83f003e150..777db05811 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -380,20 +380,35 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The narrowest the Method menu is drawn (points): the width it had for Classic and Perceptual. +const METHOD_MENU_MIN: f32 = 104.0; +/// The room the Method menu keeps beside its longest name for its arrow and margins (points). +const METHOD_MENU_PAD: f32 = 40.0; + /// The Method row (Illustrator 30): how a linear or radial gradient mixes the colours between its -/// stops, Classic or Perceptual. +/// stops, Classic or Perceptual. Hue Clockwise and Hue Counterclockwise, VectorCraft's own +/// methods, mix them around the hue wheel one way or the other. fn method_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { - let methods = [(GradientMethod::Classic, tl!("Classic")), (GradientMethod::Perceptual, tl!("Perceptual"))]; + let methods = [ + (GradientMethod::Classic, tl!("Classic")), + (GradientMethod::Perceptual, tl!("Perceptual")), + (GradientMethod::HueClockwise, tl!("Hue Clockwise")), + (GradientMethod::HueCounterclockwise, tl!("Hue Counterclockwise")), + ]; let current = g.gradient.method; let shown = methods.iter().find(|(m, _)| *m == current).map_or("", |(_, l)| *l); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Method:")); let labels = methods.map(|(_, l)| l); - let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-method", shown, &labels, 104.0)).inner; + // Wide enough for the longest method name in the interface language and the arrow beside + // it, within the room the row has. + let widest = labels.iter().map(|l| widgets::text_width(ui, egui::RichText::new(*l).size(12.0))).fold(0.0, f32::max); + let width = (widest + METHOD_MENU_PAD).clamp(METHOD_MENU_MIN, ui.available_width().max(METHOD_MENU_MIN)); + let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-method", shown, &labels, width)).inner; if let Some((m, _)) = chosen.and_then(|i| methods.get(i)) && *m != current { - edit(app, json!({"method": m.label().to_lowercase()}), Live::Released); + edit(app, json!({"method": m.id()}), Live::Released); } }); } diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 589dbc523a..d5362b8bab 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -328,6 +328,31 @@ fn the_method_menu_makes_the_gradient_perceptual() { assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); } +/// The Method menu lists the two hue methods after Classic and Perceptual; choosing one mixes +/// the gradient around the hue wheel that way, as one undo step, and the menu then shows it. +#[test] +fn the_method_menu_mixes_the_gradient_around_the_hue_wheel() { + for (label, method) in [("Hue Clockwise", GradientMethod::HueClockwise), ("Hue Counterclockwise", GradientMethod::HueCounterclockwise)] { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Classic").expect("Classic shown").1; + let undo = app.session.doc().unwrap().history.undo.len(); + let texts = p.click(&mut app, shown); + for listed in ["Classic", "Perceptual", "Hue Clockwise", "Hue Counterclockwise"] { + assert!(texts.iter().any(|(t, _)| t == listed), "the menu lists {listed}: {texts:?}"); + } + let option = texts.iter().find(|(t, _)| t == label).expect("the menu lists the method").1; + p.click(&mut app, option); + assert_eq!(fill(&app).gradient.method, method, "{label}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "{label}: one undo step"); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == label), "the menu shows the method: {texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); + } +} + /// The Spread menu makes the gradient repeat (one undo step), and the Repeats field, dimmed while /// it pads, then sets the count. #[test] diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index fe38851b44..b87f291e67 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -726,7 +726,7 @@ impl LibraryKind for GraphicStyleLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.style_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); lib.iter().filter(|s| found(&s.style().name)).map(|s| Row { name: &s.style().name, item: Some(s) }).collect() } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0ac14d243d..1b0c664d2f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -11,15 +11,20 @@ //! - The selection column selects a row's art (`layer.selectAll`, Shift adds or removes); a row //! whose art is selected shows a square in its layer's colour, and dragging that square onto //! another row moves the selected art there (Alt copies it). -//! - The eye and lock columns toggle a row (`layer.setProps`); dragging down a column sets every -//! row it passes to the same state, in one undo step. Ctrl/Cmd-click an eye switches the layer -//! between Preview and Outline. Alt-click an eye hides the other layers (`layer.hideOthers`), or -//! shows every layer when they are hidden (`layer.showAll`); Alt-click a lock does the same with -//! `layer.lockOthers` and `layer.unlockAll`. Ctrl/Cmd+Alt-click an eye switches the other layers -//! to Outline and the row's layer to Preview (`layer.outlineOthers`), or switches every layer to -//! Preview when the others are in Outline (`layer.previewAll`). +//! - The eye and lock columns toggle a row (`layer.setProps`), a ruler guide's row included; +//! dragging down a column sets every row it passes to the same state, in one undo step. +//! Ctrl/Cmd-click a layer's eye switches it between Preview and Outline. Alt-click an eye hides +//! the other layers (`layer.hideOthers`), or shows every layer when they are hidden +//! (`layer.showAll`); Alt-click a lock does the same with `layer.lockOthers` and +//! `layer.unlockAll`. Ctrl/Cmd+Alt-click an eye switches the other layers to Outline and the +//! row's layer to Preview (`layer.outlineOthers`), or switches every layer to Preview when the +//! others are in Outline (`layer.previewAll`). +//! - A ruler guide's row selects the guide (`guide.select`) and leaves no row highlighted, so +//! Delete then deletes the guide; dragged onto another layer's row the guide goes there +//! (`guide.setLayer`), and onto the trash it is deleted (`guide.remove`). //! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or -//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Layers +//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer, and rows +//! held near the top or bottom edge of the list scroll it, faster nearer the edge. Layers //! dropped in the empty space under the last row go below the bottom layer. Dropped on the trash //! they are deleted, on New Layer duplicated. //! - Deleting rows from the panel (the trash, rows dropped on it, Delete Selection, Delete and @@ -134,7 +139,7 @@ enum LayersDrag { /// The selected-art square: the selected objects go to the row it is dropped on. Art, /// Ruler guide rows (indexes into the document's guides): they go to the layer of the row - /// they are dropped on. + /// they are dropped on, or are deleted when dropped on the trash. Guides(Vec), } @@ -304,6 +309,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for r in list.get(range).unwrap_or_default() { row(ui, &view, r, &mut out); } + // Rows held near the list's top or bottom edge scroll it, so a row out of view can + // take the drop. + if egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) + && ui.input(|i| i.pointer.primary_down()) + { + super::scroll_at_edges(ui); + } }, ) }); diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs index a9ee00462b..381d0a65b1 100644 --- a/crates/ui-egui/src/panels/layers/controls.rs +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -63,7 +63,8 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc { actions.push(("appearance.clear".into(), json!({"ids": [id.0]}))); } else if trash.clicked() { - if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) { + // With no row highlighted, the selected art and ruler guides go. + if view.rows.is_empty() && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) { actions.push(("edit.clear".into(), json!({}))); } else { actions.push(("ui.layerDelete".into(), json!({}))); @@ -82,15 +83,22 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc } } // Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are - // outlined while rows (or, on the trash, a target circle) hang over them. + // outlined while rows (or, on the trash, a target circle) hang over them. Guide rows dropped on + // the trash delete their guides (`guide.remove`); New Layer does not take them. for (button, cmd) in [(&trash, "ui.layerDelete"), (&new_layer, "layer.duplicate")] { - if let Some(d) = button.dnd_release_payload::() - && let LayersDrag::Rows(ids) = &*d - { - actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); + let deletes = cmd == "ui.layerDelete"; + if let Some(d) = button.dnd_release_payload::() { + match &*d { + LayersDrag::Rows(ids) => actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))), + LayersDrag::Guides(guides) if deletes => actions.push(("guide.remove".into(), json!({"indexes": guides}))), + _ => {} + } } - let over = button.dnd_hover_payload::().is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) - || (cmd == "ui.layerDelete" && button.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_)))); + let over = button.dnd_hover_payload::().is_some_and(|d| match *d { + LayersDrag::Rows(_) => true, + LayersDrag::Guides(_) => deletes, + LayersDrag::Art => false, + }) || (deletes && button.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_)))); if over { widgets::drop_target_outline(&child, button.rect); } diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index c24863ffbc..428149d776 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -62,7 +62,8 @@ pub(super) struct Row { pub(super) colour: Color32, /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. pub(super) dim: bool, - /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. + /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row, + /// dimmed when the guide or its layer is hidden. pub(super) guide: Option, /// The spine of blend `node`: its row, below the blend's key objects (#4820). pub(super) spine: bool, @@ -263,10 +264,15 @@ pub(super) fn push_rows( let searching = !query.is_empty(); let narrowing = searching || filter.is_on(); // A layer's ruler guides are rows in it, above its art, the highest `guide.list` index first - // (not while searching, nor while the filter leaves them out). - let guides: Vec = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { - let mut found: Vec = - n.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).filter_map(|c| all_guides.binary_search(&c.id).ok()).collect(); + // (not while searching, nor while the filter leaves them out), each with whether it is visible. + let guides: Vec<(usize, bool)> = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { + let mut found: Vec<(usize, bool)> = n + .children() + .into_iter() + .flatten() + .filter(|c| c.is_ruler_guide()) + .filter_map(|c| all_guides.binary_search(&c.id).ok().map(|i| (i, c.visible))) + .collect(); found.sort_unstable(); found } else { @@ -277,7 +283,7 @@ pub(super) fn push_rows( let at = out.len(); out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None, spine: false }); if open { - for i in guides.into_iter().rev() { + for (i, visible) in guides.into_iter().rev() { out.push(Row { node: n.clone(), depth: depth + 1, @@ -285,7 +291,7 @@ pub(super) fn push_rows( opens: false, open: false, colour, - dim: !shown, + dim: !shown || !visible, guide: Some(i), spine: false, }); diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 4e5b7eb218..9f80d93791 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -116,9 +116,14 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { } } -/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection -/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it -/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. +/// The row of ruler guide `i` on the layer of `item`: the guide's own eye and lock, a guide mark +/// and its name, the selection square when it is selected. The eye and lock hide and lock this +/// guide alone (`layer.setProps` on its node, one undo step), and take part in a drag down either +/// column, as other rows do; they work while its layer is hidden or locked, and the guide shows or +/// can be edited only when its layer allows it too. A click elsewhere selects the guide and leaves +/// no row highlighted; Shift or Cmd adds it to (or takes it from) the selected guides. Dragged onto +/// another layer's row the guide goes to that layer, and onto the trash it is deleted. A hidden or +/// locked guide, or one on a hidden or locked layer, can't be selected or dragged. fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); @@ -129,6 +134,9 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + if let Some(node) = g.id.and_then(|id| view.doc.node(id)) { + eye_and_lock(ui, view, node, r, out); + } let mut x = r.left() + 2.0 * COLUMN + 2.0; colour_bar(ui, &item.node, r, x, item.colour); x += 6.0 + item.depth as f32 * INDENT + 16.0; @@ -149,11 +157,17 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { } if editable && resp.clicked() { let m = ui.input(|i| i.modifiers); + // The highlighted rows are let go, so Delete then deletes the selected guide (Edit › Clear) + // rather than those rows. + if !view.rows.is_empty() { + out.actions.push(("layer.highlight".into(), json!({"ids": [], "mode": "set"}))); + out.rows_after_click = Some(vec![]); + } out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); } // Dragged: the selected guides when this is one of them, else this one, to another layer's row. - // Only the primary button drags a guide row. - if editable && resp.drag_started_by(egui::PointerButton::Primary) { + // Only the primary button drags a guide row, and not while a drag down a column passes it. + if editable && resp.drag_started_by(egui::PointerButton::Primary) && view.column.is_none() { let guides = if selected { view.guides.clone() } else { vec![i] }; egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); } @@ -426,8 +440,8 @@ fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) { (false, true) => "layer.lockOthers", (false, false) => "layer.unlockAll", }; - // Show All and Unlock All take no rows. - let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) }; + // Show All and Unlock All take no rows; a ruler guide's row names its layer. + let params = if cmd.ends_with("Others") { json!({ "ids": [layer_row(n, own).0] }) } else { json!({}) }; (cmd.into(), params) } @@ -437,13 +451,19 @@ fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) { fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) { let own = doc.layer_of(n.id); if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { - ("layer.outlineOthers".into(), json!({ "ids": [n.id.0] })) + ("layer.outlineOthers".into(), json!({ "ids": [layer_row(n, own).0] })) } else { // Preview All takes no rows. ("layer.previewAll".into(), json!({})) } } +/// The row the layer commands of an Alt-click on row `n`'s eye or lock name: `n` itself, or for a +/// ruler guide's row the layer `own` that holds the guide (the layer commands refuse a guide). +fn layer_row(n: &Node, own: Option) -> NodeId { + if n.is_ruler_guide() { own.unwrap_or(n.id) } else { n.id } +} + /// The eye and lock columns of row `n` at `r`. fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) { let t = &view.t; diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 2143bd1ee3..55fa27ce58 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -835,6 +835,158 @@ fn guide_rows_select_and_move_their_guides() { assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].layer, Some(second)); } +/// A guide row's eye and lock hide and lock that guide alone, each click one undo step: a hidden +/// guide neither shows nor stays selected and its row dims, a locked one shows but can't be +/// selected. They work while the guide's layer is hidden, and a drag down the eye column passes +/// the guide row like any other. +#[test] +fn guide_rows_have_their_own_eye_and_lock() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap()); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 1, its guide (no target circle), the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 2); + let y = (circles[0].y + circles[1].y) / 2.0; + let (name, eye, lock) = (egui::pos2(150.0, y), egui::pos2(12.0, y), egui::pos2(37.0, y)); + let st = app.session.active().unwrap(); + let (layer, guide) = (st.doc.layers[0].id, st.doc.ruler_guides()[0].id.unwrap()); + let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + let shown_and_editable = |app: &VectorcraftApp| { + let d = &app.session.active().unwrap().doc; + let g = d.ruler_guide(guide).unwrap(); + (d.guide_shown(&g), d.guide_editable(&g)) + }; + let dimmed = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()).iter().find(|r| r.guide.is_some()).unwrap().dim + }; + click_with(&mut app, &ctx, name, egui::Modifiers::NONE); + assert_eq!(selected(&app), [guide]); + // The eye hides the guide alone, in one step, and deselects it. + let before = undo(&app); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert!(!node(&app, guide).visible && node(&app, layer).visible && node(&app, rect).visible); + assert_eq!((undo(&app), shown_and_editable(&app), dimmed(&app)), (before + 1, (false, false), true)); + assert!(selected(&app).is_empty(), "a hidden guide can't stay selected"); + click_with(&mut app, &ctx, name, egui::Modifiers::NONE); + assert!(selected(&app).is_empty(), "its row doesn't select a hidden guide"); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert_eq!((node(&app, guide).visible, shown_and_editable(&app), dimmed(&app)), (true, (true, true), false)); + // The lock locks it alone: it still shows, and its row no longer selects it. + click_with(&mut app, &ctx, lock, egui::Modifiers::NONE); + assert!(node(&app, guide).locked && !node(&app, layer).locked); + assert_eq!((undo(&app), shown_and_editable(&app), dimmed(&app)), (before + 3, (true, false), false)); + click_with(&mut app, &ctx, name, egui::Modifiers::NONE); + assert!(selected(&app).is_empty(), "its row doesn't select a locked guide"); + run(&mut app, "edit.undo", json!({})); + assert!(!node(&app, guide).locked); + run(&mut app, "edit.redo", json!({})); + assert!(node(&app, guide).locked); + click_with(&mut app, &ctx, lock, egui::Modifiers::NONE); + assert_eq!(shown_and_editable(&app), (true, true)); + // With its layer hidden, the guide's own eye still changes the guide, which shows again with + // the layer only when its own eye is on. + run(&mut app, "layer.setProps", json!({"ids": [layer.0], "visible": false})); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert!(!node(&app, guide).visible); + run(&mut app, "layer.setProps", json!({"ids": [layer.0], "visible": true})); + assert_eq!(shown_and_editable(&app), (false, false)); + click_with(&mut app, &ctx, eye, egui::Modifiers::NONE); + assert_eq!(shown_and_editable(&app), (true, true)); + // A drag down the eye column from the guide row to the rectangle hides both, in one step. + let before = undo(&app); + let below = egui::pos2(12.0, circles[1].y); + let steps = [ + vec![egui::Event::PointerMoved(eye)], + vec![button(eye, true)], + vec![egui::Event::PointerMoved(eye + vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(below)], + vec![button(below, false)], + vec![], + ]; + for e in steps { + frame(&mut app, &ctx, e, false); + } + assert!(!node(&app, guide).visible && !node(&app, rect).visible && node(&app, layer).visible); + assert_eq!(undo(&app), before + 1, "one undo step"); + assert!(!app.session.in_interaction()); + run(&mut app, "edit.undo", json!({})); + assert!(node(&app, guide).visible && node(&app, rect).visible); + // Alt-click on the guide's eye or lock acts on the other layers, as on any row in its layer. + run(&mut app, "layer.new", json!({})); + let other = app.session.active().unwrap().doc.layers.iter().map(|l| l.id).find(|l| *l != layer).unwrap(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 3, "the new layer, Layer 1 and the rectangle"); + let y = (circles[1].y + circles[2].y) / 2.0; + click_with(&mut app, &ctx, egui::pos2(12.0, y), egui::Modifiers::ALT); + assert!(!node(&app, other).visible && node(&app, layer).visible && node(&app, guide).visible); + click_with(&mut app, &ctx, egui::pos2(37.0, y), egui::Modifiers::ALT); + assert!(node(&app, other).locked && !node(&app, layer).locked && !node(&app, guide).locked); +} + +/// A guide row dropped on the trash deletes its guide, in one undo step; with no row highlighted +/// the trash deletes the guide its row selected; and a click on a guide row lets go of the +/// highlighted rows, so Delete goes to Edit › Clear, which deletes the guide and not the layer. +#[test] +fn guide_rows_are_deleted_with_the_trash_and_delete() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap()); + for pos in [50, 80] { + run(&mut app, "guide.add", json!({"vertical": true, "pos": pos})); + } + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: Layer 1, the 80 pt guide, the 50 pt guide, the rectangle. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 2); + let h = (circles[1].y - circles[0].y) / 3.0; + let (row_80, row_50) = (egui::pos2(150.0, circles[0].y + h), egui::pos2(150.0, circles[0].y + 2.0 * h)); + let positions = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let kept = |app: &VectorcraftApp| { + let d = &app.session.active().unwrap().doc; + d.layers.len() == 1 && d.node(rect).is_some() + }; + let trash = trash_pos(&mut app, &ctx); + let before = undo(&app); + drag(&mut app, &ctx, row_80, trash, false); + assert_eq!((positions(&app), undo(&app)), (vec![50.0], before + 1)); + assert!(kept(&app)); + run(&mut app, "edit.undo", json!({})); + assert_eq!(positions(&app).len(), 2); + // The trash, with the guide selected through its row and no row highlighted. + click_with(&mut app, &ctx, row_50, egui::Modifiers::NONE); + assert!(app.session.active().unwrap().highlighted_rows().is_empty()); + click_with(&mut app, &ctx, trash, egui::Modifiers::NONE); + assert_eq!(positions(&app), [80.0]); + assert!(kept(&app) && app.ui.dialog.is_none()); + run(&mut app, "edit.undo", json!({})); + // Layer 1 highlighted, then the guide's row clicked: the panel lets Delete go. + let layer = app.session.active().unwrap().doc.layers[0].id; + click_with(&mut app, &ctx, egui::pos2(150.0, circles[0].y), egui::Modifiers::NONE); + assert_eq!(app.session.active().unwrap().highlighted_rows(), [layer]); + click_with(&mut app, &ctx, row_80, egui::Modifiers::NONE); + assert!(app.session.active().unwrap().highlighted_rows().is_empty()); + let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![del], false); + assert!(kept(&app) && app.ui.dialog.is_none() && !crate::panels::rows_take_delete(&ctx)); + assert_eq!(positions(&app).len(), 2); + app.run("edit.clear", json!({})).unwrap(); + assert_eq!(positions(&app), [50.0]); + assert!(kept(&app)); +} + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows /// holding selected art). fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { @@ -1232,6 +1384,202 @@ fn dragging_rows_moves_them_into_other_layers_and_alt_copies() { let _ = (layer, c); } +/// One headless frame of the panel on a short screen (300 × 260) with `events` (Alt held when +/// `alt`): for every row name painted inside the list's view, its center, and the view. +fn short_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, alt: bool) -> (HashMap, egui::Rect) { + timed_short_frame(app, ctx, events, alt, 1.0 / 60.0) +} + +/// [`short_frame`] for a frame that comes `dt` seconds after the one before it. +fn timed_short_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + alt: bool, + dt: f32, +) -> (HashMap, egui::Rect) { + let mut events = events; + events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 260.0)); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), predicted_dt: dt, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + // The rows' eye column dividers are drawn clipped to the list's view. + let border = Tokens::get(ctx).input_border; + let view = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::LineSegment { stroke, .. } if stroke.color == border && c.clip_rect.height() < screen.height() => Some(c.clip_rect), + _ => None, + }) + .unwrap_or(egui::Rect::NOTHING); + let mut names = HashMap::new(); + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape + && t.galley.text().starts_with("Layer ") + && view.contains_rect(c.clip_rect) + { + let center = t.pos + t.galley.size() / 2.0; + if c.clip_rect.contains(center) { + names.insert(t.galley.text().to_string(), center); + } + } + } + (names, view) +} + +/// A document of 30 empty layers named `Layer 1` (the bottom one) to `Layer 30` (the top one): +/// more rows than [`short_frame`]'s list shows. +fn thirty_layers() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + for i in 2..=30 { + app.session.execute("layer.new", &json!({"top": true, "name": format!("Layer {i}")})).unwrap(); + } + app +} + +/// The layers' names, bottom first. +fn layer_names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.layers.iter().map(|l| l.display_name()).collect() +} + +/// Hold the button still at `at` (a row is held) until `name`'s row is fully in view; → its center. +fn hold_until(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, name: &str, alt: bool) -> Option { + short_frame(app, ctx, vec![egui::Event::PointerMoved(at)], alt); + for _ in 0..500 { + let (names, view) = short_frame(app, ctx, vec![], alt); + if let Some(&c) = names.get(name) + && c.y - ROW_MEDIUM / 2.0 >= view.top() + && c.y + ROW_MEDIUM / 2.0 <= view.bottom() + { + return Some(c); + } + } + None +} + +#[test] +fn rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view() { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let (names, view) = short_frame(&mut app, &ctx, vec![], false); + assert!(names.contains_key("Layer 30") && !names.contains_key("Layer 1"), "the list overflows: {names:?}"); + let before = (undo(&app), layer_names(&app)); + // Layer 30 held still just above the list's bottom edge: the list scrolls down to Layer 1. + let grab = names["Layer 30"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], false); + short_frame(&mut app, &ctx, vec![button(grab, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab + vec2(0.0, 6.0))], false); + let edge = egui::pos2(grab.x, view.bottom() - 3.0); + let target = hold_until(&mut app, &ctx, edge, "Layer 1", false).expect("Layer 1 scrolled into view while the row was held"); + assert_eq!((undo(&app), layer_names(&app)), before, "scrolling alone changes nothing"); + // Dropped on the upper edge of Layer 1's row: above it. + let drop = target - vec2(0.0, ROW_MEDIUM * 0.4); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(drop)], false); + short_frame(&mut app, &ctx, vec![button(drop, false)], false); + let names = layer_names(&app); + assert_eq!(&names[..2], ["Layer 1", "Layer 30"], "{names:?}"); + assert_eq!(undo(&app), before.0 + 1); + // Released, the list stays where it is with the pointer in the top band. + let (rows, _) = short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(drop.x, view.top() + 3.0))], false); + for _ in 0..10 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "no scrolling after the release"); + } + // Layer 2, Alt-dragged to the top edge: the list scrolls back up, and dropped on the lower edge + // of Layer 29's row a copy goes below it. + let grab = rows["Layer 2"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], true); + short_frame(&mut app, &ctx, vec![button(grab, true)], true); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab - vec2(0.0, 6.0))], true); + let (_, view) = short_frame(&mut app, &ctx, vec![], true); + let target = hold_until(&mut app, &ctx, egui::pos2(grab.x, view.top() + 3.0), "Layer 29", true).expect("Layer 29 scrolled into view"); + let drop = target + vec2(0.0, ROW_MEDIUM * 0.4); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(drop)], true); + short_frame(&mut app, &ctx, vec![button(drop, false)], true); + let names = layer_names(&app); + assert_eq!(names.len(), 31); + assert_eq!((names[28].as_str(), names[30].as_str()), ("Layer 28", "Layer 29"), "{names:?}"); + assert!(names[29].starts_with("Layer 2"), "the copy, below Layer 29: {names:?}"); + assert_eq!(names[2], "Layer 2", "the original stays"); +} + +#[test] +fn a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate() { + // Layer 30 held just above the list's bottom edge for half a second, drawn in `frames` frames, + // then moved beside the list, where the scrolling stops: → where the rows in view came to rest. + let held = |frames: u16| { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let (names, view) = short_frame(&mut app, &ctx, vec![], false); + let grab = names["Layer 30"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], false); + short_frame(&mut app, &ctx, vec![button(grab, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab + vec2(0.0, 6.0))], false); + let dt = 0.5 / f32::from(frames); + timed_short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(grab.x, view.bottom() - 3.0))], false, dt); + for _ in 1..frames { + timed_short_frame(&mut app, &ctx, vec![], false, dt); + } + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(view.right() + 2.0, view.bottom() - 3.0))], false); + let mut rows = HashMap::new(); + for _ in 0..120 { + rows = short_frame(&mut app, &ctx, vec![], false).0; + } + // (Layer 30's dimmed copy follows the pointer.) + rows.remove("Layer 30"); + rows + }; + let (at_30, at_120) = (held(15), held(60)); + assert!(!at_30.contains_key("Layer 29") && !at_30.is_empty(), "the list scrolled: {at_30:?}"); + assert_eq!(at_30.len(), at_120.len(), "{at_30:?} {at_120:?}"); + for (name, a) in &at_30 { + let b = at_120.get(name).unwrap_or_else(|| panic!("{name} is in view at 30 frames a second only: {at_120:?}")); + assert!((a.y - b.y).abs() < 2.0, "{name}: {a:?} at 30 frames a second, {b:?} at 120"); + } +} + +#[test] +fn the_list_scrolls_only_for_rows_held_over_it() { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let (rows, view) = short_frame(&mut app, &ctx, vec![], false); + // The pointer in the bottom band with no button down: nothing scrolls. + let edge = egui::pos2(150.0, view.bottom() - 3.0); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(edge)], false); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "hovering scrolls nothing"); + } + // A row held in the bottom band beside the list (right of its view): nothing scrolls, and + // the release there moves nothing. + let before = layer_names(&app); + let grab = rows["Layer 30"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], false); + short_frame(&mut app, &ctx, vec![button(grab, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab + vec2(0.0, 6.0))], false); + let beside = egui::pos2(view.right() + 2.0, view.bottom() - 3.0); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(beside)], false); + // (Layer 30's dimmed copy follows the pointer, so another row is compared.) + assert!(rows.contains_key("Layer 27")); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0.get("Layer 27"), rows.get("Layer 27"), "held beside the list"); + } + short_frame(&mut app, &ctx, vec![button(beside, false)], false); + short_frame(&mut app, &ctx, vec![], false); + assert_eq!(layer_names(&app), before); + // The eye column dragged down into the band: a column drag, which scrolls nothing. + let eye = egui::pos2(12.0, grab.y); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(eye)], false); + short_frame(&mut app, &ctx, vec![button(eye, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(eye + vec2(0.0, 6.0))], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(12.0, view.bottom() - 3.0))], false); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "a column drag"); + } + short_frame(&mut app, &ctx, vec![button(egui::pos2(12.0, view.bottom() - 3.0), false)], false); +} + /// The centres of the bottom bar's buttons, right to left from under the right end of its top /// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object. fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 84f72e5bdc..3b55d34e11 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -51,8 +51,6 @@ const LIST_VIEW: &str = "library-list-view"; const BAR: f32 = 42.0; /// The least height the list takes. const MIN_LIST: f32 = 140.0; -/// How near the list's top or bottom edge a drag scrolls it. -const EDGE: f32 = 28.0; /// The current library: its id and a copy of it (the panel menu acts on it after reading). fn current(app: &VectorcraftApp) -> Option<(String, Library)> { @@ -329,7 +327,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); if items.grab.is_some() { - scroll_at_edges(ui); + super::scroll_at_edges(ui); } }); out.inner_rect @@ -713,22 +711,6 @@ fn no_group_strip(ui: &mut Ui) { ui.add_space(6.0); } -/// While something is dragged near the top or bottom edge of the scrolled list (`ui`: its -/// contents), scroll that way, faster nearer the edge. -fn scroll_at_edges(ui: &mut Ui) { - let Some(pos) = ui.ctx().pointer_hover_pos() else { return }; - let view = ui.clip_rect(); - if !view.x_range().contains(pos.x) { - return; - } - let speed = |depth: f32| if (0.0..EDGE).contains(&depth) { 2.0 + 10.0 * (1.0 - depth / EDGE) } else { 0.0 }; - let delta = speed(pos.y - view.top()) - speed(view.bottom() - pos.y); - if delta != 0.0 { - ui.scroll_with_delta(vec2(0.0, delta)); - ui.ctx().request_repaint(); - } -} - /// The drag in progress held over a section of the list (`sections`, seen through `clip`), away /// from the items that take one: an item goes into that section's group (the ungrouped section: /// out of its group), and something new is added there, the section outlined; a group goes diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index a4d9b4c0d8..fdfb2f7c3b 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -104,8 +104,13 @@ pub(crate) trait LibraryKind { fn find(app: &VectorcraftApp, key: &str) -> Found; /// The file library `id` was read from, if any. fn path(app: &VectorcraftApp, id: &str) -> Option; - /// The rows matching the find field's `query` (lower case; empty: all). - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec>; + /// The rows of library `id` matching the find field's `query` (lower case; empty: all). + fn rows<'a>(lib: &'a Self::Lib, id: &str, query: &str) -> Vec>; + /// Row `name` of library `id` as its list row and tooltips show it (by default as it is). The + /// row keeps `name` for its tile, selection, clicks, drags and the commands they run. + fn label<'a>(_id: &str, name: &'a str) -> &'a str { + name + } /// An item's thumbnail in `r`. fn draw(app: &VectorcraftApp, ui: &Ui, r: Rect, item: &Self::Item, selected: bool, hovered: bool); /// The icons at the right of list row `r`. @@ -626,7 +631,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { let (view, hide_find) = settings(&app.ui, K::KIND, id).map_or((K::VIEW, false), |s| (s.view.unwrap_or(K::VIEW), s.hide_find)); let query = if hide_find { String::new() } else { widgets::search_field(ui, egui::Id::unique(key(&panel, "find")), tl!("Find")) }; ui.add_space(4.0); - let rows = K::rows(&lib, &query.trim().to_lowercase()); + let rows = K::rows(&lib, id, &query.trim().to_lowercase()); let selected = selection(ui.ctx(), &panel); let is_sel = |n: &str| selected.iter().any(|s| s == n); let mut clicked: Option<(String, bool, egui::Modifiers)> = None; @@ -659,9 +664,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { } None => super::swatches::draw_folder(ui, chip), } - list_row_name(ui, r, chip, row.name); + let label = K::label(id, row.name); + list_row_name(ui, r, chip, label); drag_out::(ui, &resp, id, row); - let tip = || row.item.map_or_else(|| row.name.to_string(), |i| format!("{} ({})", row.name, K::describe(i))); + let tip = || row.item.map_or_else(|| label.to_string(), |i| format!("{label} ({})", K::describe(i))); click(ui, resp, row, &tip); } } else { @@ -672,7 +678,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { None => folder_tile(ui, cell, is_sel(row.name)), } drag_out::(ui, &resp, id, row); - click(ui, resp, row, &|| row.name.to_string()); + click(ui, resp, row, &|| K::label(id, row.name).to_string()); } } }); diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 034a90e5b0..5d6fef049b 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -127,7 +127,7 @@ fn a_plain_click_sets_where_a_shift_click_range_starts() { let ctx = context(); app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); - let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); + let order: Vec = SwatchLibraries::rows(&lib, "pastels", "").iter().map(|r| r.name.to_string()).collect(); click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index aced758482..2fb130ae3e 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -9,6 +9,10 @@ //! Show in Folder and Show DCS Transparency Interactions (a warning on the rows of images placed //! from DCS files that are transparent or overlap transparent objects). //! +//! Images and placed documents in opacity-mask art and in the art of symbols, graph designs and +//! patterns are listed after the layers' own, one row each; the canvas can't select them, so a +//! click picks such a row in the panel, and the buttons act on it as on any other. +//! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -56,6 +60,16 @@ struct Cache { at: f64, list: Value, info: Option<(u64, Value)>, + /// The objects outside the layer tree, by id: those of the rows, for their thumbnails, and + /// those whose opacity masks hold a row's image, for the tooltip. + nested: Vec<(u64, std::sync::Arc)>, +} + +impl Cache { + /// Object `id` outside the layer tree, as [`cached`] collected it. + fn nested_node(&self, id: u64) -> Option<&std::sync::Arc> { + self.nested.binary_search_by_key(&id, |(n, _)| *n).ok().and_then(|i| self.nested.get(i)).map(|(_, n)| n) + } } fn options(ctx: &egui::Context) -> Options { @@ -73,13 +87,34 @@ fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) { fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option { let st = app.session.active()?; let key = Some((st.uid, st.revision, o.show, o.sort, o.dcs)); - let first = selected_images(app).first().copied(); + let first = selected_links(app, ctx).first().copied(); let now = ctx.input(|i| i.time); let mut c: Cache = pstate(ctx, "links-cache"); let stale = c.key != key || now - c.at > RECHECK; if stale { c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?; (c.key, c.at) = (key, now); + // One pass over the stored art for the rows outside the layer tree and the objects whose + // opacity masks hold them, not one per frame. + let mut want: std::collections::HashSet = c.list["links"] + .as_array() + .into_iter() + .flatten() + .filter(|r| nested_row(r)) + .flat_map(|r| [r["id"].as_u64(), r["owner"]["maskOf"].as_u64()]) + .flatten() + .collect(); + c.nested.clear(); + if !want.is_empty() + && let Some(st) = app.session.active() + { + st.doc.visit_stored_arcs(|n, place| { + if !place.in_layers() && want.remove(&n.id.0) { + c.nested.push((n.id.0, std::sync::Arc::clone(n))); + } + }); + c.nested.sort_by_key(|(id, _)| *id); + } } let want = first.filter(|_| o.info); if stale || c.info.as_ref().map(|(id, _)| *id) != want { @@ -100,6 +135,50 @@ fn selected_images(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect() } +/// Is row `r` of an object outside the layer tree (in opacity-mask art or the art of a symbol, +/// graph design or pattern), which the canvas can't select? +fn nested_row(r: &Value) -> bool { + r["owner"].is_object() +} + +/// The active document's uid and the ids of its selected objects. +fn canvas_selection(app: &VectorcraftApp) -> Option<(u64, Vec)> { + app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())) +} + +/// The rows outside the layer tree picked in the panel, with the canvas selection +/// ([`canvas_selection`]) they were picked with. +type NestedPicks = Option<((u64, Vec), Vec)>; + +/// The rows outside the layer tree picked in the panel, in the active document. A change of the +/// canvas selection, or another document, drops them (the panel looks every frame). +fn nested_picks(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let picks: NestedPicks = pstate(ctx, "links-nested"); + let Some((at, ids)) = picks else { return vec![] }; + if Some(&at) == canvas_selection(app).as_ref() { + return ids; + } + set_pstate::(ctx, "links-nested", None); + vec![] +} + +/// Keep `ids` as the rows picked in the panel, with the canvas selection as it is now. +fn set_nested_picks(app: &VectorcraftApp, ctx: &egui::Context, ids: Vec) { + set_pstate(ctx, "links-nested", canvas_selection(app).map(|at| (at, ids))); +} + +/// The ids of the selected rows: the selected images and placed documents, then the rows outside +/// the layer tree picked in the panel. +fn selected_links(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut ids = selected_images(app); + for id in nested_picks(app, ctx) { + if !ids.contains(&id) { + ids.push(id); + } + } + ids +} + /// The rows of `list` whose image is selected. fn selected_rows<'a>(list: &'a Value, selected: &[u64]) -> Vec<&'a Value> { list["links"].as_array().into_iter().flatten().filter(|r| r["id"].as_u64().is_some_and(|id| selected.contains(&id))).collect() @@ -174,9 +253,14 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { }); } -/// Select image `id` and scroll it into view. +/// Select image `id` and scroll it into view; one outside the layer tree only says that it has no +/// place on the canvas. pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) { let Ok(r) = app.run("links.goTo", json!({ "id": id })) else { return }; + if r["onCanvas"] == false { + app.status("The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + return; + } let b: Vec = r["bounds"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); if let (&[x0, y0, x1, y1], Some(v)) = (&b[..], app.view_mut()) { v.center = vectorcraft_geom::Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); @@ -275,7 +359,31 @@ fn badge(ui: &Ui, r: egui::Rect, status: &str) { } } -fn status_tip(r: &Value) -> String { +/// The tooltip of row `r`. A row outside the layer tree also names where it is stored +/// (`links.list`'s `owner`): the symbol, graph design or pattern whose art holds it, and the object +/// whose opacity mask holds it, by `mask` (that object's name) or else by its id. +pub(super) fn status_tip(r: &Value, mask: Option) -> String { + let tip = file_tip(r); + let owner = &r["owner"]; + let name = owner["name"].as_str().unwrap_or_default(); + let art = match owner["kind"].as_str() { + Some("symbol") => Some(tl!("Symbols")), + Some("graphDesign") => Some(tl!("Graph Design")), + Some("pattern") => Some(tl!("Pattern")), + _ => None, + }; + let tip = match art { + Some(kind) => format!("{tip}\n{kind}: {name}"), + None => tip, + }; + match owner["maskOf"].as_u64() { + Some(id) => format!("{tip}\n{}: {}", tl!("Opacity Mask"), mask.unwrap_or_else(|| id.to_string())), + None => tip, + } +} + +/// The file, status and notes part of [`status_tip`]. +fn file_tip(r: &Value) -> String { let path = r["found"].as_str().or(r["path"].as_str()).unwrap_or_default(); let tip = match r["status"].as_str().unwrap_or_default() { "missing" => crate::i18n::fmt(tl!("Missing: {path}"), &[("path", path)]), @@ -299,25 +407,46 @@ fn status_tip(r: &Value) -> String { /// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked /// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A /// Shift-click while that row is not shown, or after the last click was in another document, acts -/// as a click without Shift. +/// as a click without Shift. Rows outside the layer tree are picked in the panel itself, as the +/// canvas can't select them; a click without Ctrl/Cmd replaces both kinds of selection. fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) { let Some(uid) = app.session.active().map(|st| st.uid) else { return }; let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor"); let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id)); + let nested = |r: &Value| nested_row(r).then(|| r["id"].as_u64()).flatten(); + let mut picks = if m.command { nested_picks(app, ctx) } else { vec![] }; if m.shift && let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid) && let (Some(i), Some(j)) = (at(a), at(id)) { - let range: Vec = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect(); - app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok(); + let range = rows.get(i.min(j)..=i.max(j)).unwrap_or_default(); + let canvas: Vec = range.iter().filter(|r| !nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + picks.extend(range.iter().filter_map(nested).filter(|id| !picks.contains(id)).collect::>()); + app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": canvas })).ok(); + set_nested_picks(app, ctx, picks); return; } set_pstate(ctx, "links-anchor", Some((uid, id))); + let row = rows.iter().find(|r| r["id"].as_u64() == Some(id)); + if row.is_some_and(nested_row) { + match picks.iter().position(|p| *p == id) { + Some(i) if m.command => { + picks.remove(i); + } + _ => picks.push(id), + } + if !m.command { + app.run("select.set", json!({ "ids": [] })).ok(); + } + set_nested_picks(app, ctx, picks); + return; + } if m.command { app.run("select.toggle", json!({ "id": id })).ok(); } else { app.run("select.set", json!({ "ids": [id] })).ok(); } + set_nested_picks(app, ctx, picks); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -331,7 +460,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let thumbs = super::layers::ThumbSource::of(app, st, &doc); - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); let mut clicked: Option<(u64, egui::Modifiers, bool)> = None; widgets::list_box(ui, |ui| { @@ -363,7 +492,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if o.thumb > 0.0 { let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); - if let Some(n) = super::layers::node_arc(&doc, NodeId(id)) { + let node = + if nested_row(r) { cache.nested_node(id).map(std::sync::Arc::clone) } else { super::layers::node_arc(&doc, NodeId(id)) }; + if let Some(n) = node { super::layers::real_thumb(ui, &thumbs, &n, tr); } ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); @@ -395,7 +526,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.5), t.text, ); - let resp = resp.on_hover_text(status_tip(r)); + // The mask's object by the layer tree's index or from the cache: no walk of the + // stored art each frame. + let mask = r["owner"]["maskOf"] + .as_u64() + .and_then(|m| doc.node(NodeId(m)).or_else(|| cache.nested_node(m).map(|n| &**n))) + .map(|n| super::layers::shown_name(app, n, crate::i18n::current())); + let resp = resp.on_hover_text(status_tip(r, mask)); if resp.double_clicked() { clicked = Some((id, egui::Modifiers::NONE, true)); } else if resp.clicked() { @@ -424,7 +561,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::icon_button_enabled(ui, "pencil", tl!("Edit Original"), false, can_edit, 24.0).clicked() { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } if widgets::icon_button_enabled(ui, "rotate-cw", tl!("Update Link"), false, !modified.is_empty(), 24.0).clicked() { let r = app.run("links.update", json!({ "ids": modified })); @@ -465,6 +602,15 @@ fn break_link(app: &mut VectorcraftApp, ids: Vec) { }); } +/// The params of Edit Original and Show in Folder for the selected rows `sel`: the first linked +/// row whose file is there (rows outside the layer tree included), else none (the selection's). +fn file_row(sel: &[&Value]) -> Value { + match ids(sel, |r| r["linked"] == true && !status_is(r, "missing")).first() { + Some(id) => json!({ "id": id }), + None => json!({}), + } +} + /// Pick-a-file actions need a file dialog (the desktop app). fn can_pick(app: &VectorcraftApp) -> bool { app.services.pick_open.is_some() @@ -551,7 +697,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let o = options(ui.ctx()); // The rows the panel shows (the menu opens from it). let list = pstate::(ui.ctx(), "links-cache").list; - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let sel = selected_rows(&list, &selected); let linked = ids(&sel, |r| r["linked"] == true); let embedded: Vec<&Value> = sel.iter().copied().filter(|r| status_is(r, "embedded")).collect(); @@ -576,7 +722,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { report(app, r, |v| updated_status(v["updated"].as_array().map_or(0, Vec::len))); } if menu_item(ui, tl!("Edit Original"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } ui.separator(); if menu_item(ui, tl!("Embed Image(s)"), !linked.is_empty(), false) { @@ -607,7 +753,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Show in Folder"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.reveal", json!({})); + crate::menus::invoke(app, "links.reveal", file_row(&sel)); } ui.separator(); // Select Linked, Embedded, Missing and Modified: every such image in the document, not only diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 07b53cc6d8..6195de4b24 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -389,12 +389,15 @@ fn trace_section(app: &mut VectorcraftApp, ui: &mut Ui, preset: &str, view: vect }); } -/// The Objects on Path groups selected with nothing else: their ids, and the Pivot and Rotate they -/// share (`None` where they differ). +/// The Objects on Path groups selected with nothing else: their ids, the Pivot and Rotate they +/// share (`None` where they differ), and whether Detach can take an object off the path: one group, +/// neither hidden nor locked, with an object chosen with its Select/Move widget that is neither +/// hidden nor locked. struct ObjectsOnPathSelection { ids: Vec, pivot: Option, rotate: Option, + detachable: bool, } /// The selection as Objects on Path groups ([`ObjectsOnPathSelection`]), when every selected @@ -405,7 +408,11 @@ fn selected_objects_on_path(app: &VectorcraftApp) -> Option out = Some(ObjectsOnPathSelection { ids: vec![id.0], pivot: Some(rec.pivot), rotate: Some(rec.rotate) }), + None => { + let chosen = st.selection.on_path_member.filter(|m| st.selection.objects.len() == 1 && rec.members.iter().any(|x| x.id == *m)); + let detachable = chosen.is_some_and(|m| st.doc.is_editable(*id) && st.doc.is_editable(m)); + out = Some(ObjectsOnPathSelection { ids: vec![id.0], pivot: Some(rec.pivot), rotate: Some(rec.rotate), detachable }); + } Some(s) => { s.ids.push(id.0); if s.pivot != Some(rec.pivot) { @@ -425,7 +432,8 @@ fn selected_objects_on_path(app: &VectorcraftApp) -> Option). A change goes to every /// selected group in one undo step (`object.objectsOnPath.options`). Where the groups differ, no -/// Pivot square is chosen and Rotate is blank. +/// Pivot square is chosen and Rotate is blank. Detach takes the object chosen with its Select/Move +/// widget off the path (`object.objectsOnPath.detach`), and is dimmed until one is chosen. fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsOnPathSelection) { section_header(ui, tl!("Objects on Path Options")); let fw = super::field_width(ui); @@ -452,6 +460,12 @@ fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsO } ui.end_row(); }); + let w = ui.available_width(); + if ui.add_enabled_ui(oop.detachable, |ui| widgets::flat_button(ui, tl!("Detach"), w)).inner.clicked() + && let Err(e) = app.run("object.objectsOnPath.detach", json!({})) + { + app.status(e); + } } /// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5ed020c326..9300619765 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1879,6 +1879,39 @@ fn objects_on_path_options_set_pivot_and_rotate_in_one_undo_step() { assert_eq!(undo_depth(&app), depth + 1, "one undo step for both groups"); } +/// The objects of the Objects on Path group `id`, in their order along the path. +fn on_path(app: &VectorcraftApp, id: &Value) -> Vec { + let d = &app.session.active().unwrap().doc; + let rec = d.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().extras().objects_on_path.as_deref().unwrap(); + rec.members.iter().map(|m| m.id.0).collect() +} + +/// The help page's Detach in the Objects on Path Options section takes the object chosen with its +/// Select/Move widget off the path in one undo step and selects it. It is dimmed, and a click does +/// nothing, until one group is selected with an object chosen. +#[test] +fn detach_takes_the_chosen_object_off_the_path() { + let (mut app, ctx) = reference_setup(&[]); + let group = objects_on_path(&mut app, 150.0, 0.0); + let [first, second] = on_path(&app, &group)[..] else { panic!("two objects") }; + assert!(shows(&panel_frame(&mut app, &ctx, vec![], objects_on_path_options), "Detach")); + let depth = undo_depth(&app); + click_named(&mut app, &ctx, objects_on_path_options, "Detach"); + assert_eq!((on_path(&app, &group), undo_depth(&app)), (vec![first, second], depth), "no object chosen: dimmed"); + let other = objects_on_path(&mut app, 250.0, 0.0); + app.run("object.objectsOnPath.selectMember", json!({"member": first})).unwrap(); + app.run("select.add", json!({"ids": [other]})).unwrap(); + click_named(&mut app, &ctx, objects_on_path_options, "Detach"); + assert_eq!(on_path(&app, &group), [first, second], "two groups: dimmed"); + app.run("object.objectsOnPath.selectMember", json!({"member": first})).unwrap(); + let depth = undo_depth(&app); + click_named(&mut app, &ctx, objects_on_path_options, "Detach"); + assert_eq!(on_path(&app, &group), [second]); + assert_eq!(undo_depth(&app), depth + 1, "one undo step"); + let sel = &app.session.active().unwrap().selection; + assert_eq!((sel.objects.iter().map(|i| i.0).collect::>(), sel.on_path_member), (vec![first], None)); +} + /// #3759: with area type selected, the Properties panel's Paragraph section shows Align Top, Align /// Center, Align Bottom and Justify Vertically; a click sets the alignment on every selected frame /// in one undo step. Point type shows none; frames with rows or columns and threaded frames turn diff --git a/crates/ui-egui/src/panels/swatches/libraries.rs b/crates/ui-egui/src/panels/swatches/libraries.rs index 772bc8e28d..864f6ae7e0 100644 --- a/crates/ui-egui/src/panels/swatches/libraries.rs +++ b/crates/ui-egui/src/panels/swatches/libraries.rs @@ -83,7 +83,7 @@ impl LibraryKind for SwatchLibraries { } /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name /// matches shows all of them), as in the Swatches panel. - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 04f99c3c1f..ad081ddfcd 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -496,7 +496,7 @@ impl LibraryKind for SymbolLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.symbol_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); lib.iter().filter(|s| found(s.name())).map(|s| Row { name: s.name(), item: Some(s) }).collect() } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 77dd07e15f..bc1f9352c3 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -10,7 +10,7 @@ use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject}; -use super::{first_selected, pstate, set_pstate}; +use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; @@ -330,12 +330,14 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec, f64)> { /// As [`current`], with all the attributes of the paragraph whose stops the panel shows, its left /// indent as laid out ([`ParaStyle::laid_out_left_indent`]: its list level's indent added), where -/// the ruler shows it. +/// the ruler shows it. Without the Type tool editing text, the panel shows the first paragraph of +/// the first selected text object, also when that text is in a selected group or follows a +/// selected object that is not text. fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { let editing = super::character::text_editing(app); let n = match editing { Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(), - None => first_selected(app)?, + None => super::character::first_selected_text_node(app)?.clone(), }; let NodeKind::Text(t) = &n.kind else { return None }; let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index d6bcef15f1..6472b1017b 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -27,6 +27,83 @@ fn panel_draws_empty_with_text_and_with_stops() { assert_eq!(back[1]["align"], json!("decimal")); } +/// A rectangle and two area texts, the first with a stop at 40 pt and the second with one at +/// 90 pt, nothing selected. → the app and the ids of the rectangle and the two texts. +fn mixed_selection_app() -> (VectorcraftApp, [u64; 3]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let rect = id(app.run("shape.rectangle", json!({"x": 10, "y": 200, "width": 40, "height": 40})).unwrap()); + let a = id(app.run("text.create", json!({"x": 10, "y": 20, "text": "a\tb", "area": {"width": 300, "height": 60}})).unwrap()); + let b = id(app.run("text.create", json!({"x": 10, "y": 100, "text": "c\td", "area": {"width": 250, "height": 60}})).unwrap()); + for (text, position) in [(a, 40), (b, 90)] { + app.run("select.set", json!({"ids": [text]})).unwrap(); + app.run("text.tabs.set", json!({"stops": [{"position": position}]})).unwrap(); + } + app.run("select.set", json!({"ids": []})).unwrap(); + (app, [rect, a, b]) +} + +/// With a rectangle selected first and a text after it, the panel shows the text's stops on its +/// ruler, not the "No text selected" state. +#[test] +fn the_ruler_shows_text_selected_after_an_object_that_is_not_text() { + let (mut app, [rect, a, _]) = mixed_selection_app(); + app.run("select.set", json!({"ids": [rect, a]})).unwrap(); + let (node, stops, span) = current(&app).unwrap(); + assert_eq!(node.id.0, a); + assert_eq!(stops.iter().map(|s| s.position).collect::>(), [40.0]); + assert_eq!(span, 300.0); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0")); +} + +/// With a group selected first whose text follows a rectangle in it, the panel shows that text's +/// stops, ahead of a text selected after the group. +#[test] +fn the_ruler_shows_text_inside_a_selected_group() { + let (mut app, [rect, a, b]) = mixed_selection_app(); + app.run("select.set", json!({"ids": [rect, b]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, a]})).unwrap(); + let (node, stops, span) = current(&app).unwrap(); + assert_eq!(node.id.0, b); + assert_eq!(stops.iter().map(|s| s.position).collect::>(), [90.0]); + assert_eq!(span, 250.0); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0")); +} + +/// A stop added on the ruler of a mixed selection sets the first text's stops plus the new one on +/// every selected text, and leaves the rectangle as it was. +#[test] +fn a_stop_added_on_a_mixed_selection_sets_every_selected_text() { + let (mut app, [rect, a, b]) = mixed_selection_app(); + let shape = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().clone(); + let before = shape(&app); + app.run("select.set", json!({"ids": [rect, a, b]})).unwrap(); + let ctx = egui::Context::default(); + drag_on_ruler(&ctx, &mut app, 60.0, 60.0, false); + for text in [a, b] { + assert_eq!(text_of(&app, text).para_at(0).tabs.iter().map(|s| s.position).collect::>(), [40.0, 60.0], "text {text}"); + } + assert_eq!(shape(&app), before); +} + +/// A selection without text keeps the "No text selected" state and draws no ruler. +#[test] +fn a_selection_without_text_shows_no_ruler() { + let (mut app, [rect, ..]) = mixed_selection_app(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 200, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [rect, id]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + assert!(current(&app).is_none()); + let ctx = egui::Context::default(); + assert!(ruler_labels(&ctx, &mut app).is_empty()); + assert!(crate::tests_labels::painted_text(&mut app, show).contains("No text selected")); +} + /// In vertical type the Left and Right alignment buttons are named Top-Justified Tab and /// Bottom-Justified Tab, in their tool tips and for screen readers, and the position field is /// labeled Y. Horizontal type keeps Left-Justified Tab, Right-Justified Tab and X. diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 263342741a..6da376c7ca 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -103,7 +103,7 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() { assert!(has(&app, "Teardrop") && has(&app, "Double Line")); // Find filters by name; list rows give the type. let lib = BrushLibraries::get(&app, "art-ribbons").unwrap().1; - let rows = BrushLibraries::rows(&lib, "tear"); + let rows = BrushLibraries::rows(&lib, "art-ribbons", "tear"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Teardrop"]); assert_eq!(BrushLibraries::describe(rows[0].item.unwrap()), "Art"); // Dragging a brush out of the panel carries it. @@ -127,7 +127,7 @@ fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { let r = app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("ink-and-wash"), Some(4))); let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; - let rows = BrushLibraries::rows(&lib, ""); + let rows = BrushLibraries::rows(&lib, "ink-and-wash", ""); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]); let path = app.run("shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60})).unwrap()["id"].as_u64().unwrap(); let (undo, brushes) = (undo_len(&app), count(&app)); @@ -140,6 +140,48 @@ fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen"); } +/// A built-in library lists its brushes in the UI language, and Find matches the name shown and +/// the brush's own name. The brush keeps its own name for its tile, a click and the document's +/// Brushes. A library loaded from a document lists its brushes as they are named. +#[test] +fn built_in_library_brushes_are_listed_in_the_ui_language() { + let dir = std::env::temp_dir().join(format!("vc-brush-names-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = app(); + let ctx = context(); + crate::i18n::set_current(crate::i18n::Lang::from_code("fr").expect("fr registered")); + let list = |app: &mut VectorcraftApp, t: f64| { + let panel = last_open(&app.ui).map(LibraryPanel::panel).unwrap_or_default(); + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + frame(&ctx, vec![], t, |ui| library_panel::show_window(app, ui.ctx())); + frame(&ctx, vec![], t + 0.25, |ui| library_panel::show_window(app, ui.ctx())) + }; + app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); + let texts = list(&mut app, 0.0); + assert!(texts.iter().any(|t| t == "Encre effilée"), "{texts:?}"); + assert!(!texts.iter().any(|t| t == "Tapered Ink"), "{texts:?}"); + let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; + for query in ["effil", "tapered"] { + let rows = BrushLibraries::rows(&lib, "ink-and-wash", query); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink"], "{query}"); + } + assert!(BrushLibraries::rows(&lib, "loaded/1", "effil").is_empty(), "another library's brush is matched by its own name only"); + click(&mut app, &ctx, "Tapered Ink", Modifiers::NONE, 1.0); + assert!(has(&app, "Tapered Ink")); + assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Tapered Ink"); + // The document's brushes, opened as a library, keep their names. + let doc = dir.join("Inks.vectorcraft"); + let b64 = app.run("document.serialize", json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + std::fs::write(&doc, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + app.run("window.brushLibrary.other", json!({"path": doc.to_string_lossy()})).unwrap(); + let texts = list(&mut app, 2.0); + assert!(texts.iter().any(|t| t == "Tapered Ink"), "{texts:?}"); + assert!(!texts.iter().any(|t| t == "Encre effilée"), "{texts:?}"); + crate::i18n::set_current(crate::i18n::Lang::EN); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 215cf7148a..8055c1288c 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -132,6 +132,103 @@ fn shift_click_selects_a_range_of_links_and_ctrl_click_toggles_one() { assert_eq!(selected(&app), id(&[3])); } +/// A linked image kept only in a symbol's art has a row of its own: a click picks it in the panel +/// (the canvas selection is cleared), Link Info shows it, Ctrl/Cmd-click adds an image in the +/// layers, a change of the canvas selection drops the pick, and Go To clears the selection and +/// says it has no place on the canvas. +#[test] +fn a_link_inside_a_symbol_is_listed_and_picked_in_the_panel() { + let dir = Folder::new("nested"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let inside = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": inside, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let outside = dir.file("layer.png", &png(20, 20)); + let layer = app.run("file.place", json!({"path": outside, "at": [300, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "inside.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let shown = |app: &mut VectorcraftApp, time: f64| -> Vec { + let raw = egui::RawInput { time: Some(time), ..Default::default() }; + super::tests_appearance::frame_raw(&ctx, app, raw, super::links::show).into_iter().map(|(t, _)| t).collect() + }; + assert!(shown(&mut app, 0.5).iter().any(|t| t == "20 × 20 px"), "Link Info of the selected image in the layers"); + click_row(&mut app, &ctx, "inside.png", egui::Modifiers::NONE, 1.0); + assert!(selected(&app).is_empty(), "the canvas can't select it"); + assert!(shown(&mut app, 1.5).iter().any(|t| t == "30 × 20 px"), "Link Info of the symbol's image"); + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 2.0); + assert_eq!(selected(&app), [layer]); + // Both rows are selected until the canvas selection changes: then the panel's pick goes too. + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 3.0); + assert!(selected(&app).is_empty()); + assert!(shown(&mut app, 3.5).iter().any(|t| t == "30 × 20 px"), "the symbol's image is still picked"); + app.run("select.set", json!({"ids": [layer]})).unwrap(); + assert!(shown(&mut app, 4.0).iter().any(|t| t == "20 × 20 px")); + app.run("select.set", json!({"ids": []})).unwrap(); + let text = shown(&mut app, 4.5); + assert!(!text.iter().any(|t| t == "30 × 20 px" || t == "20 × 20 px"), "nothing is picked: {text:?}"); + // Go To clears the selection and says the link has no place on the canvas. + app.run("select.set", json!({"ids": [layer]})).unwrap(); + super::links::go_to(&mut app, row["id"].as_u64().unwrap()); + assert_eq!(app.ui.status, "The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + assert!(selected(&app).is_empty(), "the image in the layers is no longer selected"); +} + +/// The tooltip of a row outside the layer tree says where the image is stored, so two rows of the +/// same file in different symbols or masks can be told apart. +#[test] +fn a_nested_rows_tooltip_names_what_holds_it() { + let tip = |owner: serde_json::Value, mask: Option<&str>| { + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png", "owner": owner}); + super::links::status_tip(&row, mask.map(str::to_string)) + }; + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark"}), None), "/x/a.png\nSymbols: Mark"); + assert_eq!(tip(json!({"kind": "pattern", "name": "Dots"}), None), "/x/a.png\nPattern: Dots"); + assert_eq!(tip(json!({"kind": "graphDesign", "name": "Bar"}), None), "/x/a.png\nGraph Design: Bar"); + assert_eq!(tip(json!({"kind": "layers", "maskOf": 7}), Some("")), "/x/a.png\nOpacity Mask: "); + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark", "maskOf": 7}), None), "/x/a.png\nSymbols: Mark\nOpacity Mask: 7"); + // A row in the layers says only its file. + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png"}); + assert_eq!(super::links::status_tip(&row, None), "/x/a.png"); +} + +/// The tooltip of an image in the opacity mask of an object inside a symbol names that object, +/// which the panel finds among the objects it collected with the list. +#[test] +fn a_mask_rows_tooltip_names_its_object_inside_a_symbol() { + let dir = Folder::new("mask-owner"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let mask = dir.file("mask.png", &png(20, 20)); + let mask = app.run("file.place", json!({"path": mask, "at": [40, 40]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("transparency.makeOpacityMask", json!({"ids": [rect, mask]})).unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [rect]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "mask.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let owner = vectorcraft_doc::NodeId(row["owner"]["maskOf"].as_u64().unwrap_or_else(|| panic!("{list}"))); + let doc = app.session.active().unwrap().doc.clone(); + assert!(doc.node(owner).is_none(), "the masked object is outside the layer tree"); + let name = super::layers::shown_name(&app, doc.stored_node(owner).unwrap().0, crate::i18n::current()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec, time: f64| { + super::tests_appearance::frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show) + }; + let at = super::tests_appearance::text_rect(&frame(&mut app, vec![], 1.0), "mask.png").center(); + frame(&mut app, vec![egui::Event::PointerMoved(at)], 2.0); + frame(&mut app, vec![], 4.0); + let text: Vec = frame(&mut app, vec![], 4.1).into_iter().map(|(t, _)| t).collect(); + let want = format!("Opacity Mask: {name}"); + assert!(text.iter().any(|t| t.contains(&want)), "{want} in {text:?}"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index 46c274dee6..2adeda9812 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -92,7 +92,7 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { assert!(d.graphic_style("Feathered").is_some() && d.graphic_style("Haze").is_some()); // Find filters by name; list rows describe the styles; looks are computed once per library. let lib = GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1; - let rows = GraphicStyleLibraries::rows(&lib, "ember"); + let rows = GraphicStyleLibraries::rows(&lib, "shadows-glows", "ember"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ember"]); assert_eq!(GraphicStyleLibraries::describe(rows[0].item.unwrap()), "1 fill, Inner Glow, Outer Glow"); assert!(std::sync::Arc::ptr_eq(&lib, &GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1)); @@ -121,7 +121,7 @@ fn painted_and_sketched_opens_from_the_window_menu_and_a_click_applies_its_style let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.effects.clone(); assert_eq!(node.iter().map(|e| e.id.as_str()).collect::>(), ["sketch.charcoal"]); let lib = GraphicStyleLibraries::get(&app, "painted-sketched").unwrap().1; - let rows = GraphicStyleLibraries::rows(&lib, "ink"); + let rows = GraphicStyleLibraries::rows(&lib, "painted-sketched", "ink"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ink Contour", "Wet Ink"]); } diff --git a/crates/ui-egui/src/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index 0d70425e81..9c58bc49c7 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -89,7 +89,7 @@ fn clicking_a_library_symbol_adds_it_to_the_documents_symbols_in_one_step() { assert_eq!(symbols(&app), ["Star", "Hexagon", "Arrow"]); // Find filters by name; list rows give the natural size. let lib = SymbolLibraries::get(&app, "basic-shapes").unwrap().1; - let rows = SymbolLibraries::rows(&lib, "arr"); + let rows = SymbolLibraries::rows(&lib, "basic-shapes", "arr"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Arrow"]); assert_eq!(SymbolLibraries::describe(rows[0].item.unwrap()), "48 × 32 pt"); // Dragging a symbol out of the panel carries it to the canvas. diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 08f0819b3a..72ce8004b5 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -438,12 +438,35 @@ fn the_materials_tab_lists_the_graphics_and_maps_one_in_one_undo_step() { let nodes = controls(&mut app, &ctx); named(&nodes, "Remove Graphic"); named(&nodes, "Scale"); - // Inflate has no planar faces to map onto. + // Inflate takes no graphics. set_type(&mut app, "inflate").unwrap(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); out.textures_delta.clear(); let text = crate::tests_labels::shapes_text(&out); - assert!(text.contains("Graphics map onto planar faces"), "{text}"); + assert!(text.contains("Graphics map onto the Plane, Extrude and Revolve object types"), "{text}"); +} + +#[test] +fn on_revolve_the_materials_tab_maps_onto_the_side_without_a_face_choice() { + let (mut app, ctx) = materials_app(); + ctx.enable_accesskit(); + super::run_action(&mut app, &ctx, crate::dialogs::three_d_materials::Action::Map("Graphic".into())); + let drawn = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + crate::tests_labels::shapes_text(&out) + }; + assert!(drawn(&mut app).contains("Face")); + // Revolve keeps the graphic on its side and lists it without Face; a pick maps another. + set_type(&mut app, "revolve").unwrap(); + assert_eq!(graphics(&app).as_array().map(Vec::len), Some(1)); + let history = undo_len(&app); + super::run_action(&mut app, &ctx, crate::dialogs::three_d_materials::Action::Map("Graphic".into())); + assert_eq!(graphics(&app).as_array().map(Vec::len), Some(2)); + assert_eq!(undo_len(&app), history + 1); + named(&controls(&mut app, &ctx), "Scale"); + let text = drawn(&mut app); + assert!(text.contains("Mapped graphics") && !text.contains("Face"), "{text}"); } #[test] diff --git a/crates/ui-egui/src/script_runs.rs b/crates/ui-egui/src/script_runs.rs index 02c2229741..ddba7618d1 100644 --- a/crates/ui-egui/src/script_runs.rs +++ b/crates/ui-egui/src/script_runs.rs @@ -34,7 +34,12 @@ pub fn poll(app: &mut VectorcraftApp) { } script_run::settle_close(app); match result { - Ok(_) => app.status(format!("Ran {name}")), + // The commands it ran are journaled now: a recording they no longer fit in names its limit. + Ok(_) => { + if !crate::panels::actions::after_journaled_edit(app) { + app.status(format!("Ran {name}")); + } + } Err(e) => failed(app, e.to_string()), } app.sync_views(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 39125f8a7d..a06dc2f1df 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -621,11 +621,16 @@ pub struct UiState { /// Recording in progress: (set index, action name, journal length when recording started). #[serde(skip)] pub recording: Option<(usize, String, usize)>, - /// The id of the action New Action made for the recording in progress - /// ([`crate::panels::actions::Action::id`]): Stop gives it the recorded steps. None when Stop - /// adds a new action, as after Begin Recording. + /// The id of the action the recording in progress records into, the one New Action made or the + /// one selected when Begin Recording started it ([`crate::panels::actions::Action::id`]): Stop + /// gives it the recorded steps. None when Stop adds a new action, as after Begin Recording with + /// no action selected. #[serde(skip)] pub recording_into: Option, + /// Where in the action [`UiState::recording_into`] names Stop puts the recorded steps: before + /// the step at this index, or None after its last step. + #[serde(skip)] + pub recording_at: Option, /// The length of the action set file that Stop Playing/Recording would leave without the steps /// recorded so far, once measured; None until then, and again after an app command, which may /// change the sets. @@ -636,6 +641,10 @@ pub struct UiState { /// ([`vectorcraft_engine::Journal::rewrites`]). #[serde(skip)] pub recording_tail: Option<(u64, usize, usize, usize)>, + /// The journal as the recording in progress last checked it against the limits of an action + /// set file: (its rewrites, its length) ([`crate::panels::actions::after_journaled_edit`]). + #[serde(skip)] + pub recording_checked: Option<(u64, usize)>, /// How actions play (Actions panel menu › Playback Options…), kept with the UI preferences. #[serde(default, deserialize_with = "crate::panels::actions::deserialize_playback")] pub action_playback: crate::panels::actions::PlaybackOptions, @@ -1164,8 +1173,10 @@ impl Default for UiState { action_sets_selected: vec![], recording: None, recording_into: None, + recording_at: None, recording_base: None, recording_tail: None, + recording_checked: None, action_playback: Default::default(), action_playing: None, shortcut_overrides: Default::default(), diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index ac382547de..28ffc812a6 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -85,6 +85,12 @@ impl ClipboardRead { /// thread and the handle lives and dies there, so the handle needn't be `Send`. pub type ClipboardProbeFactory = Box Box + Send>; +/// How long quitting waits for the probe's thread ([`ClipboardProbeFactory`]) to drop its handle +/// before the app drops its own: 27 s, VectorCraft's own bound. A host's handle should finish a +/// look and its own teardown within it, or the app's handle may not be the last one dropped; a +/// handle that never lets go still lets the app quit after this wait. +pub const CLIPBOARD_PROBE_STOP_WAIT: std::time::Duration = std::time::Duration::from_secs(27); + /// The Copy or Cut whose formats go to the system clipboard ([`VectorcraftApp::publish_clipboard`]). #[derive(Default)] pub(crate) struct Publishing { diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index d7f438d285..6d7c7075c5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -242,6 +242,51 @@ fn control_export_refuses_an_option_no_format_takes() { let _ = std::fs::remove_dir_all(base); } +/// `app.export` with a path writes the file when given an option only another format reads, and +/// its warnings name that option, as `document.export`'s do without a path. `file.exportAs` run +/// as a command does the same; an export that automation did not ask for gets no such note. +#[test] +fn control_export_notes_an_option_the_format_does_not_read() { + let (mut app, tx, base) = confined("export-unused"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))})); + assert_eq!(r["ok"], true, "{r}"); + let note = json!("`quality` was not used: PDF files don't read it"); + let path = base.join("out/a.pdf"); + let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "quality": 80, "path": s(path.clone()), "wait": true})); + assert_eq!(r["ok"], true, "{r}"); + assert!(path.is_file() && r["result"]["warnings"].as_array().is_some_and(|w| w.contains(¬e)), "{r}"); + let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "quality": 80})); + assert!(r["result"]["warnings"].as_array().is_some_and(|w| w.contains(¬e)), "{r}"); + let r = request(&mut app, &tx, "app.export", json!({"format": "jpg", "quality": 80, "path": s(base.join("out/a.jpg")), "wait": true})); + assert!(r["result"]["warnings"].as_array().is_some_and(|w| !w.iter().any(|w| w.to_string().contains("was not used"))), "{r}"); + let params = json!({"format": "pdf", "quality": 80, "path": s(base.join("out/b.pdf"))}); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.exportAs", "params": params})); + assert!(r["result"]["warnings"].as_array().is_some_and(|w| w.contains(¬e)), "{r}"); + assert!(app.ui.status.contains("`quality` was not used"), "{}", app.ui.status); + let r = crate::io::export(&mut app, Some("pdf"), Some(s(base.join("out/c.pdf"))), &json!({"quality": 80})).unwrap(); + assert!(r["warnings"].as_array().is_some_and(|w| !w.contains(¬e)), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A Background Export replies before the file is written: its reply names the options the format +/// does not read at once, as a background save's does, and has no `warnings` when there are none. +#[test] +fn control_background_export_notes_an_option_the_format_does_not_read() { + let shared: SharedWriteFn = std::sync::Arc::new(|p: &str, b: &[u8]| vectorcraft_format::write_atomic(Path::new(p), b).map_err(|e| e.to_string())); + let (mut app, tx, base) = confined_with("export-unused-background", Some(shared)); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "quality": 80, "path": s(base.join("out/a.pdf"))})); + assert_eq!(r["result"]["background"], true, "{r}"); + assert_eq!(r["result"]["warnings"], json!(["`quality` was not used: PDF files don't read it"]), "{r}"); + crate::background::wait_all(&mut app); + let r = request(&mut app, &tx, "app.export", json!({"format": "jpg", "quality": 80, "path": s(base.join("out/a.jpg"))})); + assert_eq!(r["result"]["background"], true, "{r}"); + assert!(r["result"].get("warnings").is_none(), "{r}"); + crate::background::wait_all(&mut app); + assert!(base.join("out/a.pdf").is_file() && base.join("out/a.jpg").is_file()); + let _ = std::fs::remove_dir_all(base); +} + /// `app.save`, and `file.save` and `file.exportForScreens` run as commands, refuse a key no save /// format or no format takes before anything is written. #[test] diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index b443d84586..b73bb08dfa 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -248,6 +248,96 @@ fn the_menu_follows_the_selection() { assert!(items.windows(2).all(|w| !matches!(w, [Item::Sep, Item::Sep]))); } +/// A closed path of 60 jagged anchors around (150, 150), filled so that a right-click inside it +/// hits it, with nothing selected. +fn jagged(app: &mut VectorcraftApp) -> NodeId { + let anchors: Vec<_> = (0..60) + .map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 60.0; + let r = 60.0 + f64::from(i % 2) * 4.0; + json!({"x": 150.0 + r * a.cos(), "y": 150.0 + r * a.sin()}) + }) + .collect(); + let id = NodeId(app.run("path.create", json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + app.run("paint.setFill", json!({"color": "#ff8010"})).unwrap(); + app.run("select.none", json!({})).unwrap(); + id +} + +/// The anchor points of every path in the document. +fn anchor_total(app: &VectorcraftApp) -> usize { + let mut n = 0; + app.session.active().unwrap().doc.walk(|node| { + if let NodeKind::Path { path, .. } = &node.kind { + n += path.anchor_count(); + } + }); + n +} + +/// A right-click on a path lists Object › Path › Smooth… after Join and Average…. Smooth… opens +/// the Smooth slider at Auto-Smooth's level and adds no undo step while it is open; OK keeps the +/// smoothed path as one undo step. +#[test] +fn right_click_on_a_path_opens_the_smooth_slider() { + let mut app = app(); + let id = jagged(&mut app); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let p = screen(&app, 150.0, 150.0); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert_eq!(app.session.active().unwrap().selection.objects, [id], "the path right-clicked is selected"); + let y = |label| at(&texts, label).y; + assert!(y("Join") < y("Average…") && y("Average…") < y("Smooth…"), "{texts:?}"); + let (undo, before) = (app.session.active().unwrap().history.undo.len(), anchor_total(&app)); + let after = click(&mut app, &ctx, at(&texts, "Smooth…"), PointerButton::Primary); + assert!(!has(&after, "Arrange"), "choosing Smooth… closes the menu"); + let open = app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.f64("level", -1.0))); + assert_eq!(open, Some((crate::dialogs::smooth_slider::KIND.to_string(), vectorcraft_engine::SMOOTH_AUTO_LEVEL))); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step while the slider is open"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1, "OK keeps the result as one undo step"); + assert!(anchor_total(&app) < before, "the path was smoothed: {} anchor points from {before}", anchor_total(&app)); +} + +/// Smooth… is listed while the selection holds a path: a path, a group of paths, a compound path, +/// a path with type, and anchors selected with Direct Selection (whose Remove Anchor Points +/// stays). It is left out with nothing, only type or a group of type selected. +#[test] +fn smooth_follows_the_selections_paths() { + let mut app = app(); + let (a, b) = (rect(&mut app, 0.0, 0.0), rect(&mut app, 150.0, 0.0)); + let t = NodeId(app.run("text.create", json!({"x": 10, "y": 250, "text": "Type"})).unwrap()["id"].as_u64().unwrap()); + let smooth = |app: &VectorcraftApp| labels(&menus::context_items(app)).contains(&"Smooth…"); + app.run("select.none", json!({})).unwrap(); + assert!(!smooth(&app), "nothing selected"); + app.run("select.set", json!({"ids": [t.0]})).unwrap(); + assert!(!smooth(&app), "only type selected"); + let u = NodeId(app.run("text.create", json!({"x": 10, "y": 300, "text": "More"})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [t.0, u.0]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert!(!smooth(&app), "a group of type only"); + app.run("object.ungroup", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + assert!(smooth(&app), "a path"); + app.run("select.set", json!({"ids": [a.0, t.0]})).unwrap(); + assert!(smooth(&app), "a path and type"); + app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap(); + app.run("object.group", json!({})).unwrap(); + assert!(smooth(&app), "a group of paths"); + app.run("object.ungroup", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap(); + app.run("object.compoundPath.make", json!({})).unwrap(); + assert!(smooth(&app), "a compound path"); + app.run("object.compoundPath.release", json!({})).unwrap(); + app.select_tool("directSelection"); + app.run("select.anchors", json!({"id": a.0, "anchors": [[0, 1]]})).unwrap(); + assert!(smooth(&app), "an anchor point direct-selected"); + assert!(labels(&menus::context_items(&app)).contains(&"Remove Anchor Points"), "the anchor entries stay"); +} + /// Selected type has Bullets and Numbering › Apply Bullets in the context menu and the Type menu, /// which makes its paragraphs a bulleted list; other art has no such item. #[test] diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index 47d6258a1d..7ab5378e14 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -1058,6 +1058,54 @@ fn open_menu_with(id: &str, left: f32, width: f32, screen: egui::Vec2, look: Men drawn } +/// The field is drawn while other threads ask the context for repaints, as the threads that +/// render the samples do when they finish. A read of the context made while the menu already +/// holds one waits behind such a request, and a debug build of egui panics after ten seconds. +#[test] +fn the_field_draws_while_other_threads_request_repaints() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + /// Stops the threads when the test ends, failed or not. + struct Stop(Arc); + impl Drop for Stop { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } + } + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let stop = Stop(Arc::default()); + let (started, running) = std::sync::mpsc::channel(); + let threads: Vec<_> = (0..4) + .map(|_| { + let (ctx, stop, started) = (ctx.clone(), stop.0.clone(), started.clone()); + std::thread::spawn(move || { + let _ = started.send(()); + while !stop.load(Ordering::Relaxed) { + ctx.request_repaint(); + } + }) + }) + .collect(); + for _ in &threads { + running.recv().expect("the thread started"); + } + // The wheel on: the field also looks up its queued wheel input. + let look = MenuLook { wheel: true, ..Default::default() }; + for _ in 0..2000 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + font_menu(ui, "test-repaints", "Inter", 200.0, None, look); + }); + out.textures_delta.clear(); + } + drop(stop); + for thread in threads { + thread.join().expect("the thread ended"); + } +} + /// A narrow field, such as the Control bar's, opens a menu wide enough for the filters on their /// rows and for the families' names, each drawn whole beside its sample. #[test] diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index b8bb2ee6e4..815c1c8493 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -392,6 +392,22 @@ fn every_write_through_a_save_panel_asks_before_replacing() { assert!(w.borrow().is_empty()); } +/// Export As to a format without Use Artboards (a template holds the whole document) gives the +/// export no artboard option, so an export that automation confirms gets no note that one was +/// not used. +#[test] +fn export_as_a_template_is_given_no_artboard_option() { + for use_artboards in [false, true] { + let (mut app, w) = saving_app("/tmp/out"); + app.ui.answering_control = true; + app.run("file.exportAs", json!({"format": "template", "useArtboards": use_artboards})).unwrap(); + let r = dialogs::confirm(&mut app).unwrap(); + assert_eq!(last(&w), "/tmp/out.vctemplate"); + assert!(r["warnings"].as_array().is_some_and(|w| !w.iter().any(|w| w.to_string().contains("was not used"))), "{r}"); + assert!(!app.ui.status.contains("was not used"), "{}", app.ui.status); + } +} + #[test] fn typed_extensions_are_kept() { let (mut app, w) = saving_app("/tmp/art.svg"); diff --git a/crates/ui-egui/src/tests/sysclip_probe.rs b/crates/ui-egui/src/tests/sysclip_probe.rs index 05cab31bf7..1566b2bc8a 100644 --- a/crates/ui-egui/src/tests/sysclip_probe.rs +++ b/crates/ui-egui/src/tests/sysclip_probe.rs @@ -12,7 +12,7 @@ use vectorcraft_engine::Session; use vectorcraft_engine::cmd::clipboard::Flavour; use crate::tests_sysclip::{copy_rect, run}; -use crate::{Services, SystemClipboard, VectorcraftApp, menus}; +use crate::{Services, SystemClipboard, VectorcraftApp, clipboard_probe, menus}; /// What the clipboard handles saw: the thread each `has()` ran on, how many handles are alive, and /// the thread each handle was dropped on, in order. @@ -206,6 +206,87 @@ fn quitting_drops_the_probes_clipboard_before_the_apps() { assert_eq!(drops, vec![Some("vectorcraft-clip".to_owned()), ui], "handles dropped on: {drops:?}"); } +/// The probe's clipboard: dropping it tells `dropping` it started, then waits until `release` sends +/// (a handle that lets go only once an unanswered read ends), and tells `dropped` once it is gone. +struct Releasing { + rec: Arc, + dropping: std::sync::mpsc::Sender<()>, + release: std::sync::mpsc::Receiver<()>, + dropped: std::sync::mpsc::Sender<()>, +} + +impl Drop for Releasing { + fn drop(&mut self) { + let _ = self.dropping.send(()); + let _ = self.release.recv(); + self.rec.drops.lock().unwrap().push(std::thread::current().name().map(str::to_owned)); + let _ = self.dropped.send(()); + } +} + +impl SystemClipboard for Releasing { + fn write(&mut self, _flavours: &[Flavour]) -> Result<(), String> { + Ok(()) + } + fn holds_ours(&mut self) -> bool { + false + } + fn read(&mut self, _mimes: &[&'static str]) -> Option { + None + } + fn has(&mut self, _mimes: &[&'static str]) -> bool { + false + } +} + +#[test] +fn quitting_waits_while_the_probes_clipboard_is_still_letting_go() { + let rec = Arc::new(Recorder::default()); + let (dropping, dropping_seen) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel(); + let (dropped, dropped_seen) = std::sync::mpsc::channel(); + let make = { + let rec = Arc::clone(&rec); + move || -> Box { Box::new(Releasing { rec, dropping, release: released, dropped }) } + }; + let services = Services { + system_clipboard: Some(Slow::boxed(&rec, Duration::ZERO, Some(false))), + clipboard_probe: Some(Box::new(make)), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + frame(&mut app); + // A controllable clock: the probe's handle lets go 26 s after the quit (a text read unanswered + // just before it, and the worker's teardown). The wait sees the handle start to drop, then + // either reaches that time and waits for the worker, or ends at its deadline first. + let lets_go = Duration::from_secs(26); + let release_at_deadline = release.clone(); + app.clipboard_probe.as_mut().expect("the probe started").stop = Box::new(move |verdicts, quit, end| { + dropping_seen.recv().expect("the probe's handle was never dropped"); + if end >= quit + lets_go { + let _ = release_at_deadline.send(()); + while verdicts.recv().is_ok() {} + } + }); + drop(app); + // A wait that ended first left the handle dropping: let it finish to read the order. + let _ = release.send(()); + dropped_seen.recv().expect("the probe's handle was never dropped"); + let drops = rec.drops.lock().unwrap().clone(); + let ui = std::thread::current().name().map(str::to_owned); + assert_eq!(drops, vec![Some("vectorcraft-clip".to_owned()), ui], "handles dropped on: {drops:?}"); +} + +#[test] +fn quitting_waits_out_an_unanswered_x11_read_and_its_teardown() { + let quit = Instant::now(); + // A look whose text read went unanswered just before the quit: the Linux host's worker holds its + // handle until 25 s after the read was sent, and an idle worker's teardown takes 2 s at most. + assert!(clipboard_probe::stop_by(quit) >= quit + Duration::from_secs(25) + Duration::from_secs(2)); + // A handle that never lets go still lets the app quit. + assert_eq!(clipboard_probe::stop_by(quit), quit + Duration::from_secs(27)); +} + #[test] fn without_its_thread_the_check_runs_in_line_again() { let rec = Arc::new(Recorder::default()); diff --git a/crates/ui-egui/src/tests/tab_reorder.rs b/crates/ui-egui/src/tests/tab_reorder.rs index c1070fbc70..d658a178c1 100644 --- a/crates/ui-egui/src/tests/tab_reorder.rs +++ b/crates/ui-egui/src/tests/tab_reorder.rs @@ -160,8 +160,8 @@ fn views_follow_closes_and_moves_made_in_one_batch() { .unwrap(); assert_eq!(order(&app), ["Delta", "Charlie"]); assert_eq!(zooms(&app), [4.0, 3.0], "Delta does not take the closed Alpha's view"); - // Closing by index made the document in the closed one's place current (Charlie); it shows its own view. - assert_eq!((current(&app), app.view().map(|v| v.zoom)), ("Charlie".into(), Some(3.0))); + // Closing other documents by index leaves the current one (Delta) current; it shows its own view. + assert_eq!((current(&app), app.view().map(|v| v.zoom)), ("Delta".into(), Some(4.0))); // A document opened afterwards starts with a view of its own. app.run("file.new", json!({"width": 100, "height": 100, "name": "Echo"})).unwrap(); app.sync_views(); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index d5199e0220..68ef8a3f15 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -1,7 +1,8 @@ //! The Contextual Task Bar's actions for each kind of selection: open paths offer Join and Edit //! Path, compound paths and clipping masks their Release, images selected with vector art Make //! Clipping Mask, type converts between point and area type, type on a path opens its options, and -//! a basic shape shows its properties in place of Lock. +//! a basic shape shows its properties in place of Lock. Type, and the Type tools while they edit it, +//! show the font family, font style and Font Size controls. use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::json; @@ -535,6 +536,304 @@ fn the_task_bar_follows_the_active_document() { assert!(!locked(&app, rect), "the button doesn't lock the shape"); } +/// The Type tools whose bar shows the font and Fill controls. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// The labels drawn inside the task bar (none while it isn't shown), with the context. +fn bar_texts(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) { + let (ctx, texts) = bar(app); + let Some(r) = canvas::task_bar_rect(&ctx) else { return (ctx, vec![]) }; + (ctx, texts.into_iter().filter(|(_, t)| r.contains(t.center())).collect()) +} + +/// Point type "Hello" at 20 pt, made with `tool` and edited by it as after a click with it. +fn edited_type(app: &mut VectorcraftApp, tool: &str) -> u64 { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(crate::panels::character::text_editing(app).is_some(), "{tool} edits the type"); + t +} + +/// The font family and style of type `t`'s first character. +fn font_of(app: &VectorcraftApp, t: u64) -> (String, String) { + match kind(app, t) { + NodeKind::Text(t) => (t.first_style().font_family, t.first_style().font_style), + k => panic!("{k:?}"), + } +} + +/// The size of each run of type `t`, with its text. +fn sizes(app: &VectorcraftApp, t: u64) -> Vec<(String, f64)> { + match kind(app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.size)).collect(), + k => panic!("{k:?}"), + } +} + +/// Each Type tool that adds or edits type shows the font family, font style and Font Size controls +/// and the Fill, without the selection tools' object actions, the Stroke chip or Lock. +#[test] +fn the_type_tools_show_font_and_fill_controls() { + for tool in TYPE_TOOLS { + let mut app = app(); + let t = edited_type(&mut app, tool); + let (family, style) = font_of(&app, t); + let (_, texts) = bar_texts(&mut app); + for label in [family.as_str(), style.as_str(), "20 pt"] { + assert!(has(&texts, label), "{tool}: {label} in {texts:?}"); + } + for label in ["Create Outlines", "Convert To Area Type", "Dynamic Text", "Duplicate"] { + assert!(!has(&texts, label), "{tool}: {label} in {texts:?}"); + } + let tree = crate::widgets::accessibility_tests::tree(|ui| canvas::show(&mut app, ui)); + let named = |label: &str| tree.iter().any(|n| n.label().is_some_and(|l| l.starts_with(label))); + assert!(named("Fill"), "{tool}: the Fill shows"); + for label in ["Stroke", "Recolor Artwork", "Lock"] { + assert!(!named(label), "{tool}: {label} is left out"); + } + } +} + +/// A Type tool shows no bar before it adds type, nor with other art selected; the Touch Type tool +/// shows none with type selected. +#[test] +fn the_type_tools_show_no_bar_without_type() { + let mut app = app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "nothing to format"); + let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()); + select(&mut app, &[rect]); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "a rectangle is not type"); + let t = id(app.run("text.create", json!({"x": 60, "y": 180, "text": "Hello", "size": 20})).unwrap()); + app.run("tool.select", json!({"tool": "touchType"})).unwrap(); + select(&mut app, &[t]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "Touch Type has its own handles"); +} + +/// Selected type keeps its actions and gains the font controls, for one type object or several; +/// type selected with other art does not. +#[test] +fn selected_type_shows_font_controls_beside_its_actions() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + let rect = id(app.run("shape.rectangle", json!({"x": 200, "y": 40, "width": 80, "height": 60})).unwrap()); + let (family, _) = font_of(&app, a); + select(&mut app, &[a]); + let (_, texts) = bar_texts(&mut app); + for label in ["Create Outlines", "Convert To Area Type", "Duplicate", family.as_str(), "20 pt"] { + assert!(has(&texts, label), "{label} in {texts:?}"); + } + select(&mut app, &[a, b]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, family.as_str()) && has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[a, rect]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, "Group") && !has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[rect]); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); +} + +/// The bar's Font Size sets the characters a Type tool selects, one undo step; with type selected +/// it sets every selected type object, as the Character panel's does. +#[test] +fn the_task_bars_font_size_sets_what_the_character_panel_would() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + assert_eq!(sizes(&app, t), [("He".to_string(), 30.0), ("llo".to_string(), 20.0)]); + // Mixed sizes leave the field blank, as in the Character panel. + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, "20 pt") && !has(&texts, "30 pt"), "{texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&app, t), [("Hello".to_string(), 20.0)]); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + select(&mut app, &[a, b]); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "14"); + assert_eq!(sizes(&app, a), [("Hello".to_string(), 14.0)]); + assert_eq!(sizes(&app, b), [("World".to_string(), 14.0)]); +} + +/// At a caret, with no characters selected, the bar's Font Size sets the edited type as the +/// Character panel's does, and the characters typed next take it. +#[test] +fn the_task_bars_font_size_at_a_caret_sets_what_is_typed_next() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 5, "end": 5})); + assert_eq!(crate::panels::character::text_editing(&app), Some((NodeId(t), 5, 5)), "a caret after the type"); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + app.session.tool_typed_text("!", app.view_info()).unwrap(); + assert_eq!(sizes(&app, t), [("Hello!".to_string(), 30.0)]); +} + +/// The Type tool's Fill chip makes the Fill active and brings up the Swatches panel. +#[test] +fn the_type_tools_fill_chip_activates_the_fill() { + let mut app = app(); + edited_type(&mut app, "type"); + app.session.fill_active = false; + let (ctx, _) = bar_texts(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, then the Fill chip. + let p = Pos2::new(r.right() - 8.0 - 30.0 - 6.0 - 13.0, r.center().y); + let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(p)]); + frame(&mut app, &ctx, vec![press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + assert!(app.session.fill_active, "the Fill is active"); + assert!(crate::panels::character::text_editing(&app).is_some(), "the type is still edited"); +} + +/// Screen readers name the Type tool bar's controls, and the bar stays on the canvas for type at +/// its lower right corner. +#[test] +fn the_type_tools_bar_is_named_and_stays_on_the_canvas() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = app(); + edited_type(&mut app, "type"); + let tree = tree(|ui| canvas::show(&mut app, ui)); + for label in ["Font Family", "Style", "Font Size", "Fill"] { + named(&tree, label); + } + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 380, "y": 290, "text": "Corner", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // The type near the canvas's lower edge, so the bar's place under it is off the canvas. + let view = app.view_mut().unwrap(); + (view.center, view.zoom) = (vectorcraft_geom::Point::new(200.0, 20.0), 1.0); + let (ctx, _) = bar(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + let canvas = app.canvas_rect.expect("the canvas is laid out"); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); +} + +/// The task bar's rect after a few frames on a `width` by 700 screen, and the canvas's rect. +fn bar_on_screen(app: &mut VectorcraftApp, width: f32) -> (Rect, Rect) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 700.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + } + (canvas::task_bar_rect(&ctx).expect("the task bar shows"), app.canvas_rect.expect("the canvas is laid out")) +} + +/// On a canvas narrower than one row of the bar, the font controls go on a second row, narrowed to +/// fit, and the bar stays whole on the canvas: selected type's actions, and a Type tool's Fill. +#[test] +fn selected_types_font_controls_take_a_second_row_on_a_narrow_canvas() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let t = id(app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap()); + select(&mut app, &[t]); + let (r, canvas) = bar_on_screen(&mut app, 760.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // Narrower than the font controls at their full widths too. + let (r, canvas) = bar_on_screen(&mut app, 300.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + let (r, _) = bar_on_screen(&mut app, 1000.0); + assert!(r.height() < 60.0, "one row where it fits: {r:?}"); + // The Type tool's bar has only the Fill and More Options beside the controls: one row fits. + let (r, canvas) = bar_on_screen(&mut app, 460.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "one row on a 460 px screen: {r:?} in {canvas:?}"); + // Too narrow for the controls at their narrowest: the bar leaves them out. + let (r, canvas) = bar_on_screen(&mut app, 160.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "no font controls on a 160 px screen: {r:?} in {canvas:?}"); +} + +/// When the canvas narrows so far that the bar leaves its font controls out, the font family +/// field it drew before no longer takes the mouse wheel. +#[test] +fn a_task_bar_without_its_font_controls_leaves_their_wheel() { + let mut app = app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |app: &mut VectorcraftApp, width: f32| { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 700.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + } + }; + draw(&mut app, 1000.0); + let bar = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + let viewport = egui::ViewportId::ROOT; + let points = (0..(bar.width() / 4.0) as usize).flat_map(|x| (0..(bar.height() / 4.0) as usize).map(move |y| (x, y))); + let field = points + .map(|(x, y)| bar.min + vec2(x as f32 * 4.0, y as f32 * 4.0)) + .find(|p| crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, *p).is_some()) + .expect("the bar's font family field takes the wheel"); + draw(&mut app, 160.0); + assert!(canvas::task_bar_rect(&ctx).is_some(), "the bar still shows, without its font controls"); + crate::font_menu::sync_wheel_target(&app, &ctx, viewport); + assert!(crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, field).is_none(), "the left-out field at {field:?} still takes the wheel"); + + // Hiding the bar (Window › Contextual Task Bar) releases the field before the next layout. + draw(&mut app, 1000.0); + assert!(crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, field).is_some(), "the field is back"); + app.run("window.taskBar", json!({})).unwrap(); + crate::font_menu::sync_wheel_target(&app, &ctx, viewport); + assert!( + crate::font_menu::wheel_field_layer_for_test(&ctx, viewport, field).is_none(), + "the hidden bar's field at {field:?} still takes the wheel" + ); +} + +/// While a Type tool edits type, the Fill chip shows the selected characters' fill, and "?" where +/// their fills differ. +#[test] +fn the_type_tools_fill_chip_shows_the_edited_characters_fill() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 2, "end": 5})); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let fills: Vec<_> = match kind(&app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.fill.clone())).collect(), + k => panic!("{k:?}"), + }; + let [(he, he_fill), (llo, llo_fill)] = &fills[..] else { panic!("{fills:?}") }; + assert_eq!((he.as_str(), llo.as_str()), ("He", "llo")); + assert_ne!(he_fill, llo_fill); + let ctx = egui::Context::default(); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *llo_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *he_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(mixed, "the fills differ"); +} + /// The task bar shows the selection as it is after an edit and after Undo: Join leaves once the /// open path is closed and comes back with Undo, and Shape Properties shows a rectangle's new /// corner radius and then its old one. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 826e7e02f6..4e1bc4f6a5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,10 +45,10 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, popover_is_open}; pub use dropdowns::{ - dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, - panel_scroll, popover, set_menu_floor, strip_scroll, + dropdown, dropdown_icon_names, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, + menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, }; #[cfg(test)] pub(crate) use dropdowns::{menu_floor, menu_room_either}; diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 44903d6613..1539be8700 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -1,3 +1,5 @@ +use egui::IntoAtoms as _; + use super::drag::PanelDrag; use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; use super::text::take_armed_focus; @@ -33,6 +35,42 @@ pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option< }) } +/// [`dropdown_names`] whose options each show an icon before their name, in the open list and in +/// the closed field, so that a name that says little on its own is shown with a picture of what it +/// means (#6304). `icon(ui, rect, index, ink, highlighted)` paints option `index`'s icon into +/// `rect`, an `icon_size` square, with `ink`, the colour the row's name is drawn in; +/// `highlighted` is true on the open list's row of the current option, drawn on the selection +/// fill, where an accent-coloured mark would lose its contrast. `current` is the option shown; +/// `None` shows a blank field (characters that differ). +pub fn dropdown_icon_names( + ui: &mut Ui, + id: impl std::hash::Hash, + current: Option, + options: &[&str], + width: f32, + icon_size: f32, + icon: impl Fn(&Ui, Rect, usize, egui::Color32, bool), +) -> Option { + let icon = &icon; + let atom = move |i: usize, highlighted: bool| { + egui::Atom::paint(Vec2::splat(icon_size), move |ui, a| icon(ui, a.rect, i, a.fallback_text_color, highlighted)) + }; + let shown = match current.and_then(|i| options.get(i).map(|name| (i, *name))) { + Some((i, name)) => (atom(i, false), egui::RichText::new(name).size(12.0)).into_atoms(), + None => egui::RichText::new("").size(12.0).into_atoms(), + }; + combo_atoms_named(ui, id, None, shown, width, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + let on = current == Some(i); + if ui.add(egui::Button::selectable(on, (atom(i, on), *o))).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + /// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). pub fn dropdown_with( ui: &mut Ui, @@ -88,6 +126,18 @@ pub(crate) fn combo_named( current: &str, width: f32, list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + combo_atoms_named(ui, id, name, egui::RichText::new(current).size(12.0), width, list) +} + +/// [`combo_named`] whose closed field shows the atoms `current` (an icon and a name, say). +fn combo_atoms_named<'a, R>( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: impl egui::IntoAtoms<'a>, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); let label = take_previous_label(ui); @@ -99,7 +149,7 @@ pub(crate) fn combo_named( let button_id = combo_button_id(ui, &id); let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || craft_ui::ComboBox::is_open(ui.ctx(), button_id); let response = craft_ui::ComboBox::from_id_salt(HashOnly(&id)) - .selected_text(egui::RichText::new(current).size(12.0)) + .selected_text(current) .width(width - 4.0) .height(ui.spacing().combo_height) .show_ui(ui, list); @@ -146,6 +196,18 @@ pub(crate) fn combo_menu_field( current: &str, width: f32, menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, +) -> (Response, Option) { + combo_menu_field_named(ui, id, None, current, width, menu) +} + +/// [`combo_menu_field`] for a field whose visible label is not an adjacent label widget. +pub(crate) fn combo_menu_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + width: f32, + menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, ) -> (Response, Option) { let t = Tokens::get(ui.ctx()); let label = take_previous_label(ui); @@ -155,7 +217,7 @@ pub(crate) fn combo_menu_field( .corner_radius(CornerRadius::same(3)) .show(ui, |ui| { let armed = take_armed_focus(ui, &id); - let resp = combo_field(ui, id_with(ui.scope_id(), id), label, None, current, width - 4.0); + let resp = combo_field(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0); if armed { egui::Popup::open_id(ui.ctx(), egui::Popup::default_response_id(&resp)); ui.memory_mut(|m| m.stop_text_input()); diff --git a/crates/ui-egui/tests/font_wheel.rs b/crates/ui-egui/tests/font_wheel.rs index 3cdb017d77..cfa63c31de 100644 --- a/crates/ui-egui/tests/font_wheel.rs +++ b/crates/ui-egui/tests/font_wheel.rs @@ -90,18 +90,24 @@ fn a_font_field_that_appears_under_a_still_pointer_leaves_the_wheel_until_the_po #[test] fn artboard_controls_leave_replaced_font_fields_wheel_available() { - let (ctx, mut app) = selected_type_with_control(); - let mut texts = Vec::new(); - for _ in 0..8 { - texts = frame(&mut app, &ctx, vec![]); - } - let fields: Vec<_> = texts.iter().filter(|(t, _)| t == "Inter").map(|(_, point)| *point).collect(); - assert_eq!(fields.len(), 2, "Control and Properties each show a font field"); - app.select_tool("artboard"); + let laid_out = || { + let (ctx, mut app) = selected_type_with_control(); + let mut texts = Vec::new(); + for _ in 0..8 { + texts = frame(&mut app, &ctx, vec![]); + } + let fields: Vec<_> = texts.iter().filter(|(t, _)| t == "Inter").map(|(_, point)| *point).collect(); + (ctx, app, fields) + }; + let (_, _, fields) = laid_out(); + assert_eq!(fields.len(), 3, "Control, Properties and the Contextual Task Bar each show a font field"); + // Each field gets its own app, so a wheel stream begun over one field cannot decide another. for point in fields { + let (ctx, mut app, _) = laid_out(); + app.select_tool("artboard"); let mut raw = wheel_input(point); app.raw_input_hook(&ctx, &mut raw); - assert!(raw.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the Artboard tool replaces this font field"); + assert!(raw.events.iter().any(|e| matches!(e, Event::MouseWheel { .. })), "the Artboard tool replaces the font field at {point:?}"); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1a0d5c7d7d..c2d4c39b06 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). It accepts a key that only another format takes and does not use it: the reply's `warnings` and the status bar name the key ("`quality` was not used: PDF files don't read it"). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `useArtboards: false` opens it for one page of the visible art's bounds, as Export As with Use Artboards off does: the dialog has no Artboards section and its `__artBounds` field is true. `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set. With `useArtboards: false` it writes the page as `app.export` does and returns `{path, warnings}`, or `{background: true, status}` while Background Export writes it | @@ -120,7 +120,7 @@ the two can still win that race. | `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | | `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | | Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | -| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | +| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call; an `app.export` given an option the format doesn't read: also `warnings`, naming the option), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | diff --git a/docs/development.md b/docs/development.md index 962f7207f5..cbb20e3b8a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,7 +126,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: - **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and the UI thread waits 2 s at most: an owner that never sends its data costs one read 2 s, and later reads return at once until it answers. When a read gets no answer in its 2 s, such as a paste's check whether the clipboard still holds what VectorCraft copied, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, and pastes the text unless it is empty or what VectorCraft copied. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. -- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. +- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app hides its main window before these waits (`hide_before_teardown` in `apps/vectorcraft/src/app.rs`), so an X11 window, an XWayland one included, closes at once; winit cannot hide a native Wayland window, which stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. - **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 0eb68c7e25..96b07d508f 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle ([three-d.md](three-d.md#graphics)); no base materials, graphics on curved surfaces, soft shadows or ray tracing | +| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f752a8973b..1232e3ea49 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; `.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -323,6 +323,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | `.ai` and EPS import: layers that don't draw as the page, even with the drawn art of every live envelope put back, no longer put back the drawn art of every envelope of type. Each live envelope of type is weighed in the page check's own drawing of the pages it lies on, on the pixels that it and its drawn art ink there (the two drawn together on a layer of their own, without the rest of the layers; a pixel inks with an alpha above 8 of 255): it keeps its drawn art and its type, hidden, with the note, where the layers with it differ from the page in more of those pixels than its own check allows against its drawn art (10% of them, or 3% differing with all their neighbours, away from type), or where the envelopes of type that stay live, in the order they were read, would together differ there in more of a page than half of what the page check allows (a budget of the same size as the one their own checks share, kept apart from it). Art that differs elsewhere on the page no longer costs them their editable type; art that differs on their pixels still does. An envelope of type that a clip group or a clipping sublayer clips, an opacity mask masks, or a group with an appearance of its own, another envelope, a repeat or a blend holds keeps its drawn art as before, wherever it lies: drawn on its own it would be weighed elsewhere than it draws, and what holds it can draw it onto the page from off it. An envelope on no page stays live, as the page check doesn't see it, unless the page holds all the art that prints (an EPS's page that is its art's box, where art past the page is compared too): there one reaching past the page keeps its drawn art. Past 12 of them on the pages, all keep it. This holds where the editing data is trusted, where the objects that differ come in as the page's art (the placed files' other placement weighed on its own, as it draws them elsewhere under or over the envelopes; layers that draw as the page with only the envelopes the page holds put back are used as they are; where none of the objects that differ can then come in as the page's art, because an object holds type, the drawn art of every envelope of type is put back and that is tried again, as before), and for a `.ai` whose layers would open alone, which still opens alone: its envelopes of type are all settled again as they were with the page, in the same order and with the same checks, so each that opens live draws as it was weighed, and those the page held get their drawn art back. Where putting the drawn art of every live envelope back makes the layers draw as the page, it is still put back (#5065). In 224 users' files (used locally, never committed): envelopes of type that open live go from 174 to 186, in 8 files, none fewer in any file; no file changes between its layers and its PDF part; 7 renders change, by at most 0.7% of their pixels at 40 ppi (1 closer to the page, 1 alike, 5 further, by at most 0.7 of a percentage point, where VectorCraft's own envelope draws a little apart from the drawn art, within its check); the note for type in an envelope goes from 6 files. Of the 14 envelopes of type that had opened live and lost it to the page check, 2 keep their drawn art: one where the page draws something else where it is, and one in a file that comes in as its PDF part (#7141). Tests `build::candidate_tests::the_page_holds_back_only_envelopes_of_type_where_it_differs_from_them`, `envelopes_of_type_stay_live_within_half_of_what_the_page_check_allows`, `an_envelope_of_type_differing_in_more_of_its_pixels_than_its_check_allows_is_held_back`, `envelopes_of_type_the_layers_dont_draw_in_place_are_held_back`, `on_a_page_that_holds_all_the_art_an_envelope_of_type_past_it_is_held_back`, `objects_come_in_as_the_page_art_with_every_envelope_of_type_put_back_where_a_live_one_holds_a_difference`, `tests_native::layers_that_would_open_alone_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page`, `layers_that_would_open_alone_keep_live_only_the_envelopes_of_type_the_page_doesnt_hold`, `trusted_layers_that_would_open_alone_open_alone_with_an_envelope_of_type_the_page_doesnt_hold_live`, `layers_with_objects_that_come_in_as_the_page_art_keep_an_envelope_of_type_that_draws_as_the_page_live`, `trusted_layers_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page`, `trusted_layers_leave_an_envelope_of_type_the_page_draws_otherwise_its_drawn_art`, `build::candidate_tests::the_other_placement_of_placed_files_is_weighed_on_its_own`, `layers_that_draw_as_the_page_with_the_held_envelopes_of_type_put_back_are_used_so` | | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: a top-object envelope's mesh can save a side's curve in pieces. In users' files (used locally, never committed), 10 top objects save a grid of three patches across whose top and bottom edges are each one cubic split into thirds, and their drawn art follows the whole curve's parameter. VectorCraft gave each piece the share of its control polygon, so their type came out up to about half a point off the drawn art along the side, and one envelope of type kept its drawn art instead of opening live. A side whose pieces are all one curve split at anchors (each within 0.02 pt of that curve's piece) now follows that curve's parameter (#7097). Synthetic test: `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import (#6983): type from the text document is set in an installed face of the PostScript name its font record gives, one of exactly that name first. That face's family is no longer swapped for another family spelled alike but for case and punctuation (`FontDb::find_family`, which picked `SAMPLE INK` for `SampleInk`, the PostScript name of `Sample/Ink`); among the faces `FontDb::postscript_faces` lists for the name, the import takes one whose name matches in case and punctuation before one that matches only without them; and when the family and style alone resolve to another installed copy, the type names the face's version. A font record's key 5, the version string (name ID 5), picks that version among the faces of its PostScript name, also when the record spells the name with other punctuation (`Sample-Slab` for faces named `SampleSlab`); each record is resolved once. Type a PostScript program sets and legacy `.ai` type take the face of their font's name the same way. The `.ai` save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), so two such copies are two fonts of the set, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes): such type round-trips without the type attributes note, and so does a version that isn't installed, which draws in the face the font resolves to anyway. In 224 users' files (used locally, never committed; all PDF-compatible, so the PostScript and legacy paths are not exercised), 16 files set some type in another installed face; 12 render differently against their own page, 10 closer (in one from 2.55% to 0.01% of the page differing) and 2 further by 0.03% of the page (an envelope of type that now opens live, and a copy of the same widths in the version the record names); envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens in another way. The notes of how far a file's layers differ from its page shrink in 2 files and appear in 2 where an envelope of type now opens live, so the file is compared with its page. Two installed copies of one PostScript name, family and style that differ in widths, in files whose records keep no version, still take the copy the family and style resolve to: only the page's widths tell them apart. Tests: `tests_native::type_from_the_text_document_takes_the_face_of_its_exact_postscript_name`, `tests_native::type_from_the_text_document_takes_the_version_its_font_record_keeps`, `tests_fontnames::shown_type_takes_the_face_of_its_exact_postscript_name`, `ai::tests::legacy_type_takes_the_face_of_its_exact_postscript_name`, `ai_editing::tests::type_in_a_font_beside_a_family_spelled_alike_keeps_its_font`, `ai_editing::tests::type_in_another_version_of_a_static_font_keeps_it`, `ai_editing::tests::type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it`, `ai_editing::tests::a_version_longer_than_the_reader_takes_is_reported`, `vectorcraft-text` `tests::versions::every_face_of_a_postscript_name_is_found`. Scores unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index cdb15df0bd..9e95cfa29d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups ``, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | @@ -192,6 +193,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G9 Illustrator `.ai` and EPS files from real users +- **Done (#6740, 2026-11-13):** PDF import preserves Type 3 font text runs as named groups `` when drawn as outlines or color graphics, and reconstructs live text objects in `TextAs::Text` mode when `/ToUnicode` exists and the font is not a colour font (`TextAs::Outlines` produces grouped outlines; `crates/pdf/src/tests/text_placement.rs`). Scores and estimates unchanged. + - **Done (#6388, 2026-11-10):** a saved `.ai`'s text document names, for type in a variable font, where in the font's design space it is set (each axis, the named instance's or the run's own setting) and the font's version, so Montserrat Bold no longer opens as the font's default instance (Thin); two settings of one font are two fonts of the set. Test `ai_editing::tests::variable_font_instances_name_their_design_space`. The PDF part's font naming (#4548) remains with craft-libs. Estimate unchanged. - **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate. @@ -541,7 +544,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. The original polynomial distributions approximate the public description. With the Selection or Blend tool, on-canvas widgets on a blend with a spine set the color Shift and, when the steps follow - that spine, the Ramp; a blend made without a spine shows neither widget. The Blend panel shows the + that spine, the Ramp; a blend made without a spine shows the color Shift widget alone. The Blend panel shows the values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 034e009f17..f949d06071 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -156,6 +156,47 @@ under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, a four corners); `layers::tests` (a ramp, flat colors in palette entries far apart, close flat colors, a thin flat stroke, colors off the line between their entries, and Grayscale's grays, each read from both sides of the crack) and `subpixel::tests` (the rule, and one decision for a whole piece from either side). +## Staircases in smooth areas +Where a band border in a gradient or a shaded area runs at a slant, its pixels form a staircase whose +steps are two or more pixels long. One piece of outline can run along a sharp edge in the image and then +along such a border with no junction between them. That piece as a whole is not a contour (see above), +and the outline kept a corner at every step. `subpixel::outline` now reads a run of three or more edges of +1 to 8 px (`STAIR_MAX`), each turning one way where it starts and the other where it ends, with no +junction on it, as a staircase when the run's own cracks pass `subpixel::low_contrast`. A run is cut at +any step whose own cracks show half of the step between the two colors or more. The 8 px limit and the +one-half cut are VectorCraft's own values. The run's edges are then steps, as one-pixel steps already +were: the outline has no corner where two of them meet, and each gives one point, its middle, moved by the +mean of its cracks' shifts. Where a run meets an edge that is not a step, the corner between them stays, +so a straight edge longer than 8 px next to a staircase stays straight. A straight edge of 8 px or less +between two turns of opposite sign is read as one more step of the run. Inside a run, two one-pixel steps +between longer ones are read as part of the slope and do not make a notch. A staircase of longer steps, +and one whose cracks read as an edge, keep their corners, and Black and White traces do not use the rule. +The runs are the same from either side of the border. +Measured with the harness below, before and after the change: rows of the "flat colour art" inputs do not +change. On gradient and photo inputs, anchors change in 106 rows, 101 of them fewer, by −64% to +6.4%: for +example 390 to 142 (Shades of Gray, linear gradients), 1,860 to 1,537 (High Fidelity Photo, linear +gradients), 6,766 to 6,542 (High Fidelity Photo, gradient and photo) and 47 to 50 (High Fidelity Photo with +Gradients, blurred). On gradient and photo, 6 Colors, 6 Colors with Shapes, with Auto Grouping, without +Gradients and enhanced each leave a gap of 0.9 px (was 1.2) and an overlap of 1.0 px (was 0.8). Edge ΔE +of Shades of Gray on linear gradients goes from 8.36 to 8.40 (8.39 enhanced), and that of 6 Colors with +Gradients on a blurred input from 2.24 to 2.22; other ΔE and edge ΔE values change by 0.01 or less. +Lines % changes by −2 to +3, grouped % by −1 to +4, upright % by +1 in 3 rows (Shades of Gray on a blurred +input), and specks do not change. On synthetic photos of 800 and 4,000 px traced outside this harness, +the corners of rectilinear staircases per 100 px of outline fall from 0.57 to 0.46 and from 1.09 to 0.94 +with High Fidelity Photo. On the 800 px photo, the 95th percentile of the pixel difference with High +Fidelity Photo rises from 6.33 to 6.67, and 16 Colors leaves 2 staircases of steps of 2 px or more where +it left none. The tables below are the previous measurement, not a rerun. +Tests on code-drawn outlines in `subpixel::tests`: +`a_staircase_of_longer_steps_in_a_smooth_area_becomes_the_slope_through_its_steps` (a piece with a +staircase of 2 × 3 px steps in a smooth area, a straight run and the same staircase on a sharp edge: the +first gives its steps' middles and none of its corners, the straight run keeps the corners at its ends, the +second staircase keeps its corners, both regions get the same points, and 10 px steps keep their corners +in a smooth area), `a_staircase_that_runs_on_from_a_smooth_area_into_an_edge_keeps_the_edges_corners`, +`a_staircase_whose_cracks_do_not_read_as_a_contour_keeps_its_corners` (two flat colors), +`a_staircase_step_moves_by_the_mean_shift_of_its_cracks`, +`a_staircase_of_mixed_one_pixel_and_longer_steps_is_one_slope_without_a_notch`, +`a_run_of_two_steps_keeps_its_corners`, `a_junction_on_a_step_ends_the_staircase_there` and +`a_step_whose_cracks_read_as_an_edge_ends_the_staircase_there`. ## A simpler contour walk `contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the right. It used to build a table of outgoing directions for every pixel corner in a raster pass and diff --git a/docs/mcp.md b/docs/mcp.md index 2739b8b530..268cd3fe46 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -267,7 +267,8 @@ the suite) keep the agent to the folders you give it: Links in an opened document that point outside the read roots show as missing. - An error names a path the client gave as given. A path the client did not give (the document's own file in a save without `path` and in `file.revert`, also inside `action.batch`, an export preset's folder, the - Scripts folder, the document's folder in `links.relink {copy}`) is named + Scripts folder, the document's folder in `links.relink {copy}`, a New Document profile's file in + `file.newProfiles.edit` and `file.newProfiles.reset`) is named whole only inside the read roots; outside them the error names it by its file name, or as `a file or folder outside the read roots` when it has none, and keeps the roots and the reason, for example `automation path rejected: outside the write root /work/project/out: poster.vectorcraft`. @@ -630,7 +631,7 @@ sampler. | `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `set` cannot change a `__` field other than the dialog's controls held under a `__` name (such as Save PDF's `__section` or the Preferences `__search`): the `__command` and `__params` of a `confirm` or `command` dialog, and every other `__` field that holds what OK acts on, are refused with "the `` dialog cannot set protected field ``", so OK runs what the dialog was opened for. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | -| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | +| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. An app that does not send the image back writes a window capture without `path` to a file in a new folder inside the system's temporary folder (on Unix only the user can open it); the server reads the file and then removes the folder, also when the capture or the read fails. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PICT (`.pct`/`.pic`/`.pict`, and a version 2 picture of any extension), HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `_.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | @@ -758,9 +759,31 @@ designs keep their own paint. A row that had no paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do -not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and -`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store -them. +not store a series' paint. + +`appearance.addFill`, `appearance.addStroke`, `appearance.setItem`, `appearance.removeItem`, +`appearance.duplicateItem`, `appearance.moveItem`, `appearance.clear`, `appearance.reduceToBasic`, +`appearance.showAllHidden`, `appearance.transfer` (as its `target`), `graphicStyle.apply`, and `transparency.set` with a +fill or stroke `item`, given a series group's id or the selected series group, edit the paths directly in the group as +if each had been given, and the group keeps no rows of its own. A series group with no path directly in it, for example +one drawn with a column design, and a series group that has a fill or stroke row of its own, as a document saved by an +earlier version can hold, are edited themselves by these commands, and `appearance.moveItem` with `from: "contents"` +moves the group's own Contents row. `appearance.clear`, `appearance.reduceToBasic`, `appearance.showAllHidden` and +`graphicStyle.breakLink` edit the group as well as its paths. `graphicStyle.apply` without `add` and +`appearance.transfer` onto the group clear the group's own effects, opacity, blend mode, isolation, knockout and style +link, and `graphicStyle.apply` with `add` unlinks the group. With `appearance.transfer`, a `source` that is one of the +target group's paths keeps its look on a move, and a `source` that is the group's only path is an error. +`graphicStyle.redefine` gives the style to the paths directly in a series group linked to it, whether or not the group +has rows of its own, links them, and clears and unlinks the group; the source and the paths linked to the style whose +look was edited keep their look. Read as a source, by `graphicStyle.new`, `graphicStyle.redefine` or +`appearance.transfer`, a series group that has no fill, stroke, effect, opacity, blend mode, isolation, knockout or +style link of its own gives the look and the style link of the topmost path directly in it, and a move with +`appearance.transfer` clears each of those paths; a series group that has one of them is read itself. + +The other commands given a series group's id edit the group itself, and the graph does not store what they set on it: +`transparency.set` and `object.setProps` with an opacity or blend mode for the object, the `effect.*` commands and +`appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the +group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that @@ -969,7 +992,11 @@ SVG writes one file per artboard of a `range`); `useArtboards: false` exports th its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and `range`. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it -is in VectorCraft Default), the export comes back with a warning that says so. +is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with +`useArtboards: false` writes that one page too, without the native document and without the editing copy for other +apps (`editingData`), and always comes back with the warning `Preserve editing was left out: an .ai file of the art's +bounds would replace the document's artboards when reopened`; with `pdfCompatible: false` it is refused, whatever +`editingData` says. A legacy version 7 `.ai` (`aiVersion: "7"`) of the art's bounds is written as before. Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no Artboards section and the page is the bounds of the visible art. @@ -1036,7 +1063,8 @@ and `flattener` options over it (as `object.flattenTransparency` takes them); im transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless `includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. -A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, +In PDF/A-2b files, `.ai` saves and files written with `longPagesInPoints: false`, a page with a side longer than +14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2). A PDF 1.5 file with such a page is written as PDF 1.6, with a warning, and keeps PDF 1.5's encryption (128-bit RC4). A PDF/A-2b file at PDF 1.4 with such a page is written as PDF 1.6 too, with a @@ -1045,9 +1073,10 @@ unless the page is too narrow for that (below) ([veraPDF rule 6.1.13-11](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-6113-11); PDF/A-2 files may declare PDF 1.0 to 1.7, [rule 6.1.2-1](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-612-1)). PDF/X files and other PDF 1.3 and 1.4 files write such a page in points, and apps -that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at +that keep to the limit cut it to 200 in. `longPagesInPoints` (on by default; "Write pages longer than 200 in at full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without -a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. The option applies +a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. Readers that +ignore `UserUnit` show such a page at its full size. The option applies only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13), a PDF/A file at PDF 1.4 included (written as PDF 1.6). The dialog disables the @@ -1251,7 +1280,7 @@ canvas draws it as pixels, out to the miters, caps and arrowheads of a stroke. P have no diamond gradient: one of opaque stops is written as four linear gradients, one for each quarter of the square, clipped to the shape (PDF as axial shadings). In PDF and SVG, one with translucent stops, a dithered fill or a square with no area is an image, and so is a Perceptual one, except in PDF when its stops are spot colors that the -Method paragraph below describes. SVG writes one on type characters as a radial gradient through the square's +Method paragraph below describes. One mixed around the hue wheel is always an image in PDF and SVG. SVG writes one on type characters as a radial gradient through the square's corners. PDF import reads a function-based shading (type 1) whose color is the same along each square about the origin of its space, with corners on its axes, as a diamond gradient. CSS writes a radial gradient through the square's corners. A swatch library saved as CSS gets four `linear-gradient` layers, one for each quarter of the box, @@ -1295,7 +1324,7 @@ change its colors least. Each run keeps its ends; sharp steps and swatch-linked but a run with more of them than its share of the limit loses some. The same applies to a gradient along or across a stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. For CSS the 16,384 counts the gradient's stops before they are written: CSS then adds a stop for each midpoint, and for a Perceptual -gradient stops along each span's curve, so the written gradient can hold more. PDF +gradient or one mixed around the hue wheel stops along each span's curve, so the written gradient can hold more. PDF import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay @@ -1324,10 +1353,26 @@ stops whose other stops are all paper white or written in CMYK or gray, that is does not have too many stops (a diamond as four such shadings). 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. 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. +`hue-clockwise` and `hue-counterclockwise` (VectorCraft's own methods) mix a linear, radial, conic or diamond +gradient's colors around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next. +With `hue-clockwise` the hue angle falls (red, magenta, blue, cyan, green, yellow, red), which is clockwise on the +app's color wheels, and with `hue-counterclockwise` it rises. The angle keeps one direction between two stops, as the +fixed-direction rules of [CSS Color 4](https://www.w3.org/TR/css-color-4/#hue-interpolation) do, so red to blue passes +through green one way and through magenta the other. Two stops whose hues are less than half a color level apart +count as one hue and keep it, so a color and a tint or shade of it do not turn the wheel; their saturation and +brightness still change evenly, which is not the mix `classic` gives. A stop whose RGB numbers are less than half a +color level apart is gray: it takes the hue of the stop it mixes with, and a black stop takes that stop's saturation +too. SVG and PDF exports write such a gradient as an RGB image at the Document Raster Effects resolution, in a CMYK +document and with CMYK stops too, and with spot color stops (with a warning). EPS writes it as a shading in process +colors, and a legacy `.ai` file and the `.ai` editing data get the colors between its stops as stops of their own, in +process colors (at most 256, with a warning). `reverse` turns a hue method the other way with the stops, so the ramp +keeps its colors, and a Reflect spread mirrors the colors of each run. The names also read in any case, and with a +space or an underscore for the hyphen, or without it. `paint.setFill`/`paint.setStroke` take it in `gradient` too: ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"method":"perceptual"}}} +{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"method":"hue-clockwise"}}} ``` Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more colour points inside each selected @@ -1437,7 +1482,9 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR working spaces and the file stay as they are. The profiles in use are those of the document in front, after `file.new`, `document.open`, `document.activate`, `file.close`, `file.revert`, `edit.undo`, `edit.redo` and a failed `command.batch` too; with none assigned, or no document open, the working spaces are. The `workingCmyk` - target of `view.proofSetup` simulates the working CMYK space in such a document too. + target of `view.proofSetup` simulates the working CMYK space in such a document too. `color.convert`'s + `outOfGamut` and `deltaE`, `color.gamutCheck`, and the out-of-gamut warning of the Color panel and Color Picker + check against the assigned CMYK profile, else the working CMYK space; the warning updates when either changes. Not remembered: the document, anything else. Nothing is read or written when `VECTORCRAFT_NO_PREFS` is set, and a server confined with `--automation-read-root` or @@ -1576,8 +1623,18 @@ hides the dock, no panel is checked, and the panel shows again with the dock in Control bar stay as they are. The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` -and `file.export` refuse a key that no format takes, and ignore a key that only another format takes -(`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` +and `file.export` refuse a key that no format takes. They accept a key that only another format takes and do not use +it: `{"format": "pdf", "quality": 80}` writes the PDF, and the reply's `warnings` name the key +("`quality` was not used: PDF files don't read it"), in the wording of a save's note. The warning names the kind of +file written. A legacy `.ai` file (`aiVersion: "7"`) reads `outlineText`, `hiddenObjects`, `transparentImages`, the +artboard choice and `useArtboards`, and beside `aiVersion` no other option +("`compress` was not used: legacy .ai (version 7) files don't read it"). The other `.ai` files don't read those three +options or `preserveEditing`, which is always on for a `.ai` file. An export doesn't read `separateArtboards`, or +`range` for a `vectorcraft` or `template` file: a save reads them to write each artboard to a file of its own. In the +desktop app, `app.export` and `file.exportAs` give these warnings in the reply and the status bar +when automation asked for the export. An `app.export` that returns before the file is written (`background: true`) +gives them in that reply. An export that first asks a question (the one before replacing a file) and that a person +then answers gives none. `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` refuse a key that PDF doesn't take, `document.exportEps` one that EPS doesn't take and `document.exportDxf` one that DXF doesn't take. Each of them refuses an unknown key inside an option object it takes (`marks`, `bleed`, `compression`, `output`, `advanced`, `security`, `flattener`, `svg`), naming its path: @@ -1779,7 +1836,7 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd ### Parameter schemas Some commands have a machine-readable parameter schema: at present a small set including `view.capsLock`, `view.saved.new`, -`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options` and `engine.apiReference` +`view.saved.edit`, `view.saved.list`, `view.transparencyGrid`, `view.snapToGlyph.options`, `view.snapToPixel.options` and `engine.apiReference` (most of the catalogue still documents params in prose only; schemas grow command by command). The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 (https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the @@ -1832,9 +1889,10 @@ as the English gives them. An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests -more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 4 (`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Level 2 added -`engine.apiReference`; level 3 says that `rotation` in `view.saved.new` and `view.saved.list` turns clockwise. Commands that plug-ins add have no schema here. +`engine.apiReference`; level 3 says that `rotation` in `view.saved.new` and `view.saved.list` turns clockwise; level 4 +adds `view.snapToPixel.options`. Commands that plug-ins add have no schema here. `engine.apiReference {}` → `{markdown}` returns the command API reference, written from these schemas each time it runs; no file of it is kept. It needs no open document and changes nothing. The reference has a `##` section for each @@ -2022,7 +2080,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: | `global` | the global object | | `hiresTimer` | the microseconds since the run last read it, or since the run started; `0` on the web | | `level`, `memCache` | `0`; setting `memCache` throws | -| `line`, `stack` | the line of the code that reads it; the running code, outermost first, one a line as `name:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name), at most 64 lines | +| `line`, `stack` | the line of the code that reads it, counted in the file that code is in (the script, a file it included or a file `$.evalFile` ran, in this run or an earlier run of its named engine); the running code, outermost first, one a line as `name@file:line` (`[global]` for the script's own code, `[anonymous]` for a function without a name; the file as `$.fileName` gives it, or the run's name for a script given as `source`), at most 64 lines. Code that `eval` or `Function` compiled keeps the line of the source they compiled, in the file `[eval]`; code whose place a named engine no longer keeps is at line `0` of `[unknown]` | | `locale` | the interface's language as a locale such as `de` or `pt_BR` (`en` when the interface follows the system); a script can set `ll` or `ll_RR`, and `null` or `""` sets the interface's again | | `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | | `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | @@ -2480,8 +2538,8 @@ them again with the key object. When the call changes nothing, it records no und Other `object.setProps` calls leave the selection unchanged. `object.unlockAll` and `object.showAll` unlock or show every locked or hidden object in the document and select -them → `{count}`. In isolation mode they take only the objects inside the isolated object. With `scope: "group"` they -take only the objects inside the groups that hold the selected objects: +them → `{count}`. Ruler guides locked or hidden on their own are counted, and are selected as guides. In isolation +mode they take only the objects inside the isolated object. With `scope: "group"` they take only the objects inside the groups that hold the selected objects: for each selected object, the group nearest above it, or the object itself when it is a group. The objects of a group inside another such group are counted once. With no selected object in a group, the call returns the error "nothing selected is in a group" and changes nothing. `scope: "document"` is the default, and any other value is @@ -2786,12 +2844,22 @@ vertical guide, the y of a horizontal one, in points) → `{index}`. With `artbo artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is deleted with it (`artboard.delete`); without, a canvas guide runs across the whole canvas. `guide.list` → -`[{index, vertical, pos, selected, artboard?}…]` (`artboard` only for artboard guides). +`[{index, id, vertical, pos, selected, artboard?, layer?, hidden, locked, shown, editable}…]` (`artboard` only for +artboard guides; `id` is the guide's node and `layer` the layer it is on; `hidden` and `locked` are its own state, +`shown` and `editable` also take its layer into account). `layer.setProps {ids: [id…], visible?, locked?}` hides or +locks guides on their own, as the eye and lock on a guide's row in the Layers panel do; a hidden guide is not drawn +and does not snap, and a locked one is drawn and snaps but can't be selected, moved or deleted. `guide.select {indexes: [index…], toggle?}` selects guides on their own (the art is deselected; `toggle` adds or removes them) → `{selected}`. `guide.move {index, pos}` puts one guide somewhere, `guide.move {dx?, dy?, copy?}` moves the selected ones (vertical guides by `dx`, horizontal ones by `dy`; `copy` leaves them and selects the -moved copies), and `guide.remove {index?}` deletes one guide or the selected ones → `{count}`; each is one undo -step. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the arrow keys) nudges them. +moved copies), and `guide.remove {index?, indexes?}` deletes one guide, the guides `indexes` (not with `index`; an +index given twice counts once; a hidden or locked guide among them is an error and nothing is deleted) or the +selected ones → `{count}`; each is one undo step. An `indexes` list of `guide.select`, `guide.remove` or +`guide.setLayer` holds at most 10,000 entries; a longer one is an error. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the +arrow keys) nudges them. Object › Lock › Selection (`object.lock`) and Object › Hide › Selection (`object.hide`) lock +or hide the selected guides on their own, with the selected art, in one undo step, except in isolation mode. +`object.unlockAll` and `object.showAll` with the default scope, outside isolation mode, also unlock or show the +guides locked or hidden on their own and select them as guides. View › Guides › Lock Guides (`view.guides.lock`) deselects them and the paths made into guides, in every open document, and refuses these commands until unlocked. While it is on, a command that selects art, such as `edit.undo`, `edit.redo`, `object.exitIsolation`, `object.unlockAll`, `object.showAll` or `edit.paste`, leaves the guides out. @@ -2981,6 +3049,13 @@ already walks Image Trace. Short agent entry points: Tilt goes from its first value with the pen lying flat to its second upright, and on Bearing or Rotation from its first value at −180° to its second at 180°. A stroke without that input draws the brush's own value (a Scatter brush's value half way between the two). +- Freehand Fidelity: the `fidelity` of `path.freehand` and `brush.freehand` is in points, 1.5 pt when left out. A + value must be from 0.000015625 pt (0.01 / 640) to 100 / 0.0313 pt (about 3194.89 pt), and a value outside that range + is rejected with an error. The Pencil and the Paintbrush give Fidelity in screen pixels, 0.01 to 100 px, and send it + divided by the zoom (0.0313 to 640) and kept within that range, so a stroke is fitted at the same tolerance on screen + at every zoom. A stroke + that continues a path (`extend`) closes it when it ends within four times the Fidelity of the path's other end, at + least 6 pt and at most 400 pt. The Blob Brush's `path.blob` Fidelity stays in points, 0.01 to 100 pt. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), @@ -3121,17 +3196,24 @@ already walks Image Trace. Short agent entry points: would give the set more than 1,000 actions (`the action set file holds more than 1,000 actions`) or a file larger than 8 MiB (`the action set would be larger than 8 MiB`), and then no recording starts. The panel's Create New Action button and menu's New Action… open the `actionNew` dialog, whose Record runs this. -- Actions: `ui.action.record {set?}` starts recording a new action in the chosen set, or the current set when `set` - is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next available - `Action N` name in that set. The Actions panel's Begin Recording button runs this command. An invalid set or an - existing recording is an error and leaves the sets and recording unchanged, and so is a set where the new action +- Actions: `ui.action.record {set?, action?, after?}` starts recording a new action in the chosen set, or the current + set when `set` is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next + available `Action N` name in that set. With `action`, it records into the action at `action` in set `set` instead + and returns `{set, action, name}`: `ui.action.stop` puts the recorded steps after step `after` of that action, or + after its last step when `after` is left out, and the steps after them keep whether they are excluded. The Actions + panel's Begin Recording button runs this command with the selected action and its selected step, and with neither + when no action is selected. `action` without `set`, `after` without `action`, an index past the last set, action or + step, an invalid set or an existing recording is an error and leaves the sets and recording unchanged, and so is a + set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB. While a recording (this one or `ui.action.new`'s) is in progress, a recorded command whose step would give the action more than 10,000 steps, or its set's action set file more than 8 MiB, is an error with the reason (`the action already has 10,000 steps`, `the action set would be larger than 8 MiB`): it does not run, and the recording goes on. A batch (`command.batch`) is such a command too. A command whose step grows past a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands do not run, and - `ui.action.stop` leaves its step out. A stop that playback reaches + `ui.action.stop` leaves its step out. So does a `script.run` whose commands, or a tool's or panel's edit whose + step, takes the recording past a limit: the status bar names the limit when the run or the edit ends, and + `ui.action.stop` keeps the steps that fit. A stop that playback reaches then is not recorded, and the status bar shows the reason. `ui.action.stop` leaves out any recorded steps still past either limit and shows which one in the status bar. - Actions: `ui.action.options {set, action, name?, key?, cmd?, shift?, color?}` changes the action at @@ -3248,6 +3330,20 @@ already walks Image Trace. Short agent entry points: they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error, and then nothing changes. Undo does not restore a deleted step. The panel's Delete Selection runs this for the selected step. +- Actions: `ui.action.moveStep {set, action, step, toSet?, toAction?, before}` moves one step, a stop or a command, of + the action at `action` in set `set` into the action at `toAction` in set `toSet` (by default the same set and + action), before the step at index `before` there, or after its last step when `before` is its number of steps. + `before` counts the steps as they are before the move. It returns `{set, action, step, moved}` with the step's new + place. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays + selected; a moved selected step makes its new action the selected one and its set the current one. A Record Stop + dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay + after the steps that were before them. A step put back just before or just after itself does not move, and + `moved` is `false`. A missing or invalid index, or a move that would give the action more than 10,000 steps or its + set's action set file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an + error, and then nothing changes. Undo does not move the step back. In the Actions panel, dragging a step row moves + the step: a line shows where it goes, before or after the row under the pointer, or first in an action whose name + row it is over (after the last step when the action's steps are hidden), and releasing the button off those rows + leaves the step where it was. - Actions: `ui.action.playbackOptions {speed?, seconds?}` sets how the Actions panel plays actions and returns `{speed, seconds}`. `speed` is `accelerated` (the default: every step at once), `step` (one step at a time, with the window drawn after each step before the next runs) or `pause` (a pause of `seconds` between steps). `seconds` is a @@ -3256,8 +3352,8 @@ already walks Image Trace. Short agent entry points: preferences. The panel menu's Playback Options… opens the `actionPlayback` dialog (`speed`, `seconds`), whose OK runs this. Batch… (`action.batch`) plays every step at once whatever the setting. - Actions: `ui.action.stop {}` ends the recording in progress and keeps its steps, in the action `ui.action.new` made - for it, else in a new action after the last action of its set, and returns `{stopped}`. It also ends an action - playing at Step by Step or Pause For, or one waiting at a stop, whose message box closes: the steps already played + for it or the action `ui.action.record` named, at the place it named, else in a new action after the last action of + its set, and returns `{stopped}`. It also ends an action playing at Step by Step or Pause For, or one waiting at a stop, whose message box closes: the steps already played stay, and the rest do not play. With nothing recording or playing it changes nothing. The panel's Stop Playing/Recording button runs this. - Action sets: `ui.actionSet.delete {sets?: [index], ids?: [id]}` deletes the Actions panel's sets at these indexes @@ -3333,7 +3429,9 @@ already walks Image Trace. Short agent entry points: nothing when it touches none. `selectFills` off leaves the faces out, and `selectStrokes` off leaves the edges out. A click and a marquee run their command on the release, and Escape between the press and the release removes the marquee so that the release runs nothing. The tool does not highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer or on - a locked artboard, or in isolation mode outside the isolated art. + a locked artboard, or in isolation mode outside the isolated art. In the app, the tool's cursor shows what a click + at the pointer selects under these rules: the arrow with a filled square for a face, with a short line for an edge, + and with an x for nothing. It keeps the look it had at the press until the release. The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `highlight` (on), `color` (the highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the face highlight's width, 1 to 20 screen @@ -3607,12 +3705,16 @@ effect of each selected object, in one undo step: `face` is `front` (default) or offset in points of the graphic's center from the center of the object's bounds in the face's plane, `scale` is a percentage of the symbol's size (1..10000; omitted, 100 or less so the graphic fits inside the object) and `rotation` is in degrees counterclockwise (-360..360). Without `index` each object's first 3D effect takes it, and `item` is refused. -Every object's 3D effect must be Extrude & Bevel with Cap on (front only at a depth of 0) or Rotate (the Plane type, -front only), and `symbol` must name a document symbol; otherwise the command is refused and nothing changes. It returns `{ids, graphic}`. +Every object's 3D effect must be Extrude & Bevel with Cap on (front only at a depth of 0), Rotate (the Plane type, +front only) or Revolve (front only: its swept side), and `symbol` must name a document symbol; otherwise the command is refused and nothing changes. It returns `{ids, graphic}`. +On Revolve, `x` and `y` are measured on the side unrolled flat from its middle, and a graphic that does not fit +starts scaled to fit the half of the side seen from the front ([three-d.md](three-d.md#graphics)). -The graphics are the `graphics` parameter of `threeD.extrude` and `threeD.rotate`, which `effect.setParams` also +The graphics are the `graphics` parameter of `threeD.extrude`, `threeD.rotate` and `threeD.revolve`, which `effect.setParams` also edits: an array of up to 64 `{symbol, face, x, y, scale, rotation}` objects, drawn on the face turned, foreshortened and shaded with it and clipped to it ([Map artworks on 3D object](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/map-artwork-on-3d-objects.html)). +Revolve takes graphics on its swept side and shades them with the object's light ([the archived reference, page 185](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185)); +how a graphic wraps around the side is VectorCraft's own rule. A graphic naming a symbol the document does not have is refused, unless the effect already holds a graphic of it. The drawing rules are VectorCraft's own; see [three-d.md](three-d.md#graphics). @@ -4411,6 +4513,13 @@ and `layerRows`, the rows highlighted in the panel. draws and is clicked in outline, Ctrl/Cmd-click its eye), `dimImages` (0–100, or false) and `color` (an index 0–26, `#rrggbb` or a preset name; a hex color with an alpha part, `#rrggbbaa` or `#rgba`, is an error, and so it is in `layer.new` and `layer.newSublayer`). Several rows change in one undo step (a drag down the eye or lock column). + A ruler guide's row has its own eye and lock: `ids` may name ruler guides (their `id` in `guide.list`), which take + `visible` and `locked`. They can be changed while the guide's layer is hidden or locked, and the guide shows + or can be edited only when its layer allows it as well. Alt-click on a guide row's eye or lock acts on the other + layers, as on any row in that layer. A click on a guide row selects the guide (`guide.select`) and clears the + highlighted rows (`layer.highlight {ids: [], mode: "set"}`), so Delete then runs `edit.clear` on the guide; with no + row highlighted, the trash button runs `edit.clear` on the selected guides and art, and guide rows dropped on the + trash are deleted (`guide.remove {indexes}`). - **Dragging rows**: `layer.move {ids, target, place?: above|below|inside, copy?}` moves rows beside a row or into a layer or group (on top of its contents), keeping their stacking order; `copy` (Alt-drag) moves copies. Layers go only in layers or at the top level and objects in layers and groups (an object placed beside a top-level layer goes @@ -4558,7 +4667,7 @@ paper white, 0 %) exports to PDF as a Separation shading, and one that mixes spo inks exports as a DeviceN shading. A gradient that mixes a spot ink with colors written in RGB or Lab is written in process colors, with a warning; 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. Freeform gradients and dithered fills export to PDF as images in process colors, and Method -above covers Perceptual gradients. +above covers Perceptual gradients and gradients mixed around the hue wheel. ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stops":[{"offset":0,"swatch":"Ink"},{"offset":1,"swatch":"Ink","tint":20}]}}} @@ -4631,6 +4740,14 @@ tip, has more than 65,536 anchors in its art or in the shapes it paints, has sha or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` path, the `skipped` symbols and any read `error`. +Under automation read roots (see the automation roots section above), the arrowheads file counts as a path the client +did not give. `stroke.arrowheads.list` returns `file` as `null` when the file is outside the read roots, and its +`error` names the file by its file name; the heads, `skipped` and the reason stay. `stroke.arrowheads.edit` and +`stroke.arrowheads.reload` refuse a file outside the read roots with the root-check error, naming the file by its file +name: `edit` then makes no folder or file and neither opens the file nor brings it to the front, and `reload` does not +read it. Inside the read roots, `edit` opens the file under the client's roots. The Stroke panel menu's Edit +Arrowheads… is not confined. + `stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. `document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It @@ -4708,7 +4825,9 @@ and those of the object shape the outline of the clip group, the gradient fits t box, as the canvas paints the fill; the expanded art does not keep them. With `fill: false` gradient and pattern fills stay live. `object.expand.info` answers which options have something to expand in the selection (`{object, fill, stroke, gradient, appearance, symbolArt?}`; `gradient` says whether Fill has a gradient fill, which Expand -Gradient To applies to; `appearance` whether the selection has appearance attributes Expand Appearance expands first, +Gradient To applies to, that it turns into art: it is false when every such fill repeats or reflects in more than 256 +runs across the box it fills, which for type, symbol art included, is the box of each outline Object makes of a character, +ruby character, underline or strikethrough; `appearance` whether the selection has appearance attributes Expand Appearance expands first, for which Object → Expand… is dimmed in the menus; `symbolArt: {fill, stroke, gradient}`, present when the selection holds symbol instances that `object` expands, whether only their art holds it, which Fill and Stroke reach only with `object` on), and `object.mesh.create` on a gradient-filled object colours the @@ -5028,9 +5147,10 @@ round, such as a rectangle, stays as it is. Closed paths stay closed and open pa {"name":"run_command","arguments":{"command":"object.path.smooth","params":{"level":80}}} ``` -In the desktop app, Object › Path › Smooth opens the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog -`smoothSlider` with field `level`) at the Auto-Smooth level and previews the result until `ui.dialog.confirm`; -`ui.smoothAuto` moves the open slider back to the Auto-Smooth level. +In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a +path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at +the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open +slider back to the Auto-Smooth level. ## Editing a path with the Curvature tool @@ -5768,6 +5888,23 @@ know or a value that isn't a boolean is refused and changes nothing. {"name":"run_command","arguments":{"command":"view.snapToGlyph.options","params":{"angular":false,"anchors":false}}} ``` +The Pixel Snapping Options (the desktop app's Control bar › Snapping menu › Pixel Snapping Options…, described on the +help page, +): +`view.snapToPixel.options {drawing?, moving?, +scaling?}` (or `prefs.get`/`prefs.set` with key `pixelSnap`, a partial object) reads and sets which edits View › Snap to +Pixel lands on whole pixels. All three are on by default, which is VectorCraft's own default. `drawing` covers the +points the drawing tools place, the rulers' origin and measured points; `moving` covers the selection, its anchors and +segments, artboards and ruler guides moved by dragging, and the Rotate, Reflect and Shear tools' drags; `scaling` +covers the edges a bounding-box, Free Transform, artboard or slice handle drags, and the Scale tool's drag. An edit +whose option is off snaps as it does with Snap to Pixel off. With Snap to Pixel off, the options do nothing. `{}` reads +them; a key it doesn't know or a value that isn't a boolean is refused and changes nothing. Align New Objects to Pixel +Grid and the objects' pixel-aligned property (see Align to Pixel Grid) do not depend on these options. + +```json +{"name":"run_command","arguments":{"command":"view.snapToPixel.options","params":{"scaling":false}}} +``` + With Smart Guides on, a bounding-box handle dragged with the Selection or Free Transform tool (`mods.shift` proportional, `mods.alt` from the centre) lands on another object's anchor or centre (labelled "anchor" or "center"), else its corners and side line up with the edges and centres of the other objects and the artboards (a @@ -5995,8 +6132,8 @@ Preferences. `prefs.reset` resets the preferences at once, without the question. `prefs.export {path?}` writes the preferences as a JSON file, `{"format": "vectorcraft-preferences", "version": 1, "prefs": {…}}`, and replies `{path, count}`; without `path` it replies `{data, count}` with the file's text. The file -holds every preference `prefs.list` lists and the `eyedropper`, `perspectiveWidget`, `pathfinder` and `glyphSnap` -groups. It leaves out the folder preferences (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) and +holds every preference `prefs.list` lists and the `eyedropper`, `perspectiveWidget`, `pathfinder`, `glyphSnap` and +`pixelSnap` groups. It leaves out the folder preferences (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) and saved presets. An export over 1 MB, the most `prefs.import` reads, is an error and writes nothing. `prefs.import {path | data}` applies such a file and replies `{imported}`, the number of preferences it set. It checks every value first, as `prefs.set` does: a file over 1 MB, malformed JSON, another `format`, a newer @@ -6057,7 +6194,10 @@ document. With no document open they fail with "no document open". `document.act the current one. `document.move {from?, to}` reorders open documents the way dragging a document tab along the tab strip does: `from` defaults to the current document, `to` is the destination index, documents between them shift by one, the current document stays current, and each document keeps its own view (#5663). It is an engine command, so headless and -remote `command_list` both list it. +remote `command_list` both list it. `file.close {index}` on a document other than the current one leaves the current +document current, and its tool keeps its state; closing the current document makes the document in the next tab +current, or the one before it when it was the last tab. In the desktop app a modified document comes to the front first, +to ask whether to save it. `window.arrange.tile` (Window › Arrange › Tile) shows every open document in its own pane, edge to edge in the document area, in a grid of as many columns as the square root of the number of documents, rounded up, and fits each document to its pane; with fewer than two documents open it leaves the view and `tiled` as they are. It returns `{tiled, panes}`. @@ -6160,6 +6300,9 @@ with the defaults. them. - `file.newProfiles.reset {name}` deletes the category's file, so the shipped profile applies again, and returns `{name, reset}`. `reset` is false when there was no file. A copy of the profile that is open stays open. +- Under automation roots, `file.newProfiles.edit` needs write and read access to the profile's file and + `file.newProfiles.reset` needs write access. An error names the file whole only inside the read roots, and + otherwise by its file name. The profile is read on every `file.new`, so a replayed journal or a recorded action uses the profile as it is when it runs, as `document.open` reads its file when it runs. A file larger than 64 MiB (VectorCraft's own limit) is not read. @@ -6684,8 +6827,8 @@ path isn't followed) when it isn't. The desktop app also checks the active docum comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. -`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as -one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other stored object linked to the same file as +one of them, in the layers, opacity-mask art, symbols, graph designs and patterns; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in `path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, `path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, `path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are @@ -6693,8 +6836,10 @@ errors, and nothing is relinked. Each call is one undo step. The Links panel's R (`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed -documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for -those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it +documents inside symbols, graph designs, patterns and opacity masks that show the same content. Those objects have ids +of their own: `links.list`, `links.check`, `links.findFiles`, the `missingLinks`, `modifiedLinks` and `updatedLinks` +rows and the link watch list them, and every `links.*` command takes their ids. Opening a document gives an object +there whose id is 0 or already used by another object a new id, and the next save writes it. Saving a document also reads it again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be read", and `links.update`, or `document.open` when it reads the file, lists it as missing, its images keeping what they @@ -6934,8 +7079,11 @@ listed in `warnings`. Binary DXF and DWG files answer an error saying to save th ## Links panel `links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top -first, as the Links panel does: `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, -pixelHeight, path?, found?, page?, preview?, note?}` (`note`: the image takes more than the decode allowance, 2 GiB +first, as the Links panel does, then every image and placed document in opacity-mask art and in the art of symbols, +graph designs and patterns, one row per stored object (never one per symbol instance or pattern tile): `{id, name, +linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?, page?, preview?, note?, +owner?}` (`owner`, for an object outside the layer tree: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, +`maskOf` naming the object whose opacity mask holds it; `note`: the image takes more than the decode allowance, 2 GiB or 512 MiB in the web app, as RGBA or as decoded, and it is drawn at the largest size within the allowance ("shown at reduced size"; the document keeps the whole file) or, when it can't be read that way or has more pixels than the allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus @@ -6952,7 +7100,8 @@ bounds of another visible object that has one of those or a gradient with a tran a character's fill or stroke); a DCS image with transparency of its own counts as such an object. Bounds are cut to the bounds of the clipping paths of the clip groups and clipping layers holding an object. The check looks at every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error. -`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose +`links.goTo {id}` selects an image (`onCanvas: true`); for an object outside the layer tree it clears the selection and +returns `{id, onCanvas: false, owner}`, and the panel says the link has no place on the canvas. `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. `links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, @@ -9906,8 +10055,9 @@ the Selection and Blend tools show the same values as widgets 16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets `colorShift` and the lower one, shown only when the steps follow the spine as `ramp` needs, sets `ramp`, by one per screen pixel dragged along the spine and by one per arrow key -during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool -during it puts the value back: +during the drag (ten with Shift). A blend made without a spine shows the upper widget alone, placed against the +straight lines between its key centers that its steps are laid out along, which are not drawn. Each drag is one undo +step, and `press_key` Escape or a switch to another tool during it puts the value back: ```json {"name":"run_command","arguments":{"command":"object.blend.setEasing","params":{"id":40,"ramp":35}}} @@ -10025,11 +10175,26 @@ order along the path changes: Space, the start, Pivot, Rotate and the children's new place along the path counted from 0. `object.objectsOnPath.selectMember {member}` takes the same kind of `member` and returns `{group, member}`. +`object.objectsOnPath.detach {member?}` takes one object off the path: `member`, or without it the object chosen with +the Select/Move widget of the one selected group (otherwise it fails with "choose the object to detach with its +Select/Move widget"). `member` takes the same kind of object as `moveMember`, and neither it nor the group may be +hidden or locked. The object leaves the group and goes just above it in the group's layer or group, staying where it +is drawn, and becomes the selection; when the group is itself an object on another group's path, the object goes just +above that outer group, so it does not join its path. The objects after it move up the path in their order: with Auto Space they spread +evenly again (on an open path the first and last on its ends while two or more are left), and a Space value and the +start are kept, drawn as close as the path has room for. Hidden and locked objects left in the group move up too. The +group, its path and its options stay even with one object or none left, and an object moved back into it joins the +path. It is one undo step, and undo puts the object back on the path with the choice it had. It returns +`{group, member}`. In the desktop app, Detach in the Properties panel's Objects on Path Options section sends it, and +is dimmed until one group is selected with an object chosen. To take several objects off at once, drag their rows out +of the group in the Layers panel (`layer.move`). + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.options","params":{"pivot":7,"rotate":15}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.options","params":{"spacing":24,"start":0}}} +{"name":"run_command","arguments":{"command":"object.objectsOnPath.detach","params":{"member":15}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.expand","params":{}}} ``` diff --git a/docs/plugins.md b/docs/plugins.md index a932983acd..5f1479748e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -617,7 +617,7 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), `paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`) and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, - midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", + midpoint}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's. - `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or @@ -779,6 +779,11 @@ is a piece drawn by itself, in the order returned, with paints of its own: starts at 1 pt, miter join, butt cap, miter limit 10. - An effect on a fill (`_context.item` `{"fill": i}`) returns only `fills`, which replace that fill on each piece; an effect on a stroke returns only `strokes`. The object's other fills and strokes stay where they are. +- Each fill or stroke such an effect returns keeps the effects after it in that fill's or stroke's stack. A later + live effect, and a later geometry effect on strokes, runs once on each of them in paint order: it is given the + piece's `fills` and `strokes` with `_context.item` naming that one (`{"fill": 1}` for the second fill on the + piece), and its result replaces only that fill or stroke. A run that fails, is refused, is deferred or would pass + the budget leaves its own fill or stroke as it was, and the others still run. - The effects after it in the stack apply to the pieces together, the reference box being the bounds of all of them. Bounds, raster effects, Pathfinder effects, compound shapes and Envelope Options › Distort Appearance use the pieces as they are drawn. Outside outline mode, an image is also hit where the pieces of its own fills and strokes paint; a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f1981cc222..311a9b5d11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -510,7 +510,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | -| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | +| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812; distance and angle labels while drawing, #2983) | 2–4; status-bar X/Y uses the active ruler origin and General units | | File formats | 6 | 85% | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | @@ -613,6 +613,7 @@ since 2026-10-01. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. +- **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. @@ -701,6 +702,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. +- **Closing a background document tab:** closing a tab other than the current one (`file.close {index}`) keeps the current document current, and its tool keeps its state; closing the current tab still makes its neighbor current. `crates/engine/src/tests/document_close.rs`. - **Export and Import Preferences (#6435):** an optional VectorCraft feature. The Preferences dialog's Export Preferences… saves the applied preferences to a JSON file (`prefs.export`; the web build downloads it), and Import Preferences… applies such a file (`prefs.import`). The file leaves out the folder preferences. The import checks every value first and changes nothing on an error; it keeps the open document's units and records no undo step. The file format is VectorCraft's own. Tests: `crates/engine/src/tests/prefs_file.rs`, `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`. @@ -710,9 +712,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. +- **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. -- **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. +- **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `profile_errors_name_the_file_by_its_name_outside_the_read_roots`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). @@ -748,10 +751,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. + - Actions panel: with an action selected, Begin Recording adds the commands recorded to that action, after its selected step or at its end, and a step dragged within its action or to another action goes where a highlighted line shows when the button is released ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/add-commands-to-actions.html); `ui.action.record`, `ui.action.moveStep`). VectorCraft's own rules, which the page does not cover: with no action selected Begin Recording adds a new action; a step can go into an empty action or an action in another set; a release off the actions' rows leaves the step where it was; a moved step keeps whether it is excluded; Undo does not move it back. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. - - Actions panel: an action keeps to the limits that Load Actions… reads while it records and while its steps are excluded: at most 10,000 steps in an action and 8 MiB in its set's file, which are VectorCraft's own rules. Commands add steps to a recording until it stops ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)); a command whose step would pass a limit does not run, its document stays as it was, and the recording goes on. A command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit; the status bar names the limit, later recorded commands do not run, and Stop Playing/Recording leaves its step out. A stop that playback reaches is not recorded then, and the status bar shows the reason. Clearing a checkmark keeps the step in the action and skips it in playback ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html)); an exclusion that would make the set's file larger than 8 MiB changes no checkmark, and one that makes the file smaller always works. Stop Playing/Recording leaves out any recorded steps still past a limit and names the limit in the status bar. Begin Recording, Duplicate, New Action, renaming an action, Action Options…, Set Options…, New Set… and Load Actions… (`.aia` sets included) also refuse a change that would give a set more than 1,000 actions or a file larger than 8 MiB, and change nothing then. Tests `panels::actions::tests::{recorded_commands_keep_an_action_at_10_000_steps, recorded_commands_keep_an_action_set_file_within_8_mib, a_recorded_command_whose_noted_values_do_not_fit_is_left_out, a_stop_reached_while_recording_a_full_action_is_left_out, stop_leaves_out_recorded_steps_past_the_limits, excluding_steps_keeps_an_action_set_file_within_8_mib, a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows, a_set_renamed_while_it_records_is_measured_again, steps_recorded_after_an_exclusion_past_the_last_step_are_included, duplicate_keeps_action_set_files_within_their_limits, new_action_keeps_an_action_set_file_within_its_limits, renaming_keeps_an_action_set_file_within_8_mib, load_actions_refuses_a_set_that_would_save_larger_than_8_mib}`. + - Actions panel: an action keeps to the limits that Load Actions… reads while it records and while its steps are excluded: at most 10,000 steps in an action and 8 MiB in its set's file, which are VectorCraft's own rules. Commands add steps to a recording until it stops ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)); a command whose step would pass a limit does not run, its document stays as it was, and the recording goes on. A command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit; the status bar names the limit, later recorded commands do not run, and Stop Playing/Recording leaves its step out. The same holds when a script run, a tool's drag or a panel's or dialog's edit takes the recording past a limit: the edit and the journal stay, the status bar names the limit as soon as the edit ends (instead of the script's "Ran" message), and Stop Playing/Recording keeps only the steps that fit. A stop that playback reaches is not recorded then, and the status bar shows the reason. Clearing a checkmark keeps the step in the action and skips it in playback ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html)); an exclusion that would make the set's file larger than 8 MiB changes no checkmark, and one that makes the file smaller always works. Stop Playing/Recording leaves out any recorded steps still past a limit and names the limit in the status bar. Begin Recording, Duplicate, New Action, renaming an action, Action Options…, Set Options…, New Set… and Load Actions… (`.aia` sets included) also refuse a change that would give a set more than 1,000 actions or a file larger than 8 MiB, and change nothing then. Tests `panels::actions::tests::{recorded_commands_keep_an_action_at_10_000_steps, recorded_commands_keep_an_action_set_file_within_8_mib, a_recorded_command_whose_noted_values_do_not_fit_is_left_out, script_run_checks_recording_step_limit_on_completion, script_run_checks_recording_byte_limit_on_completion, tool_commit_checks_recording_step_limit, tool_commit_checks_recording_byte_limit, a_journal_rewrite_or_a_direct_commit_is_checked_on_the_next_frame, a_stop_reached_while_recording_a_full_action_is_left_out, stop_leaves_out_recorded_steps_past_the_limits, excluding_steps_keeps_an_action_set_file_within_8_mib, a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows, a_set_renamed_while_it_records_is_measured_again, steps_recorded_after_an_exclusion_past_the_last_step_are_included, duplicate_keeps_action_set_files_within_their_limits, new_action_keeps_an_action_set_file_within_its_limits, renaming_keeps_an_action_set_file_within_8_mib, load_actions_refuses_a_set_that_would_save_larger_than_8_mib}`. - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. @@ -792,6 +796,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. - Scripts: `$.fileName` is a read-only name of the script file in use ([the help page](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: it names the file of the code that reads it, so a function a file included with `#include` or run with `$.evalFile` defined, or one an earlier run of a named engine kept, gives its own file and not the calling file; code from a script given as `source`, and kept code whose place the engine dropped, gives an empty name, and code that `eval` or `Function` compiled gives the file being run. `crates/engine/src/tests/script_include.rs`, `crates/engine/src/tests/script_engines.rs`. - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. An error in code whose place is gone, or in code that `eval` or `Function` compiled, is reported without a line and column, and the code still runs. `crates/engine/src/tests/script_engines.rs`. + - Scripts: `$.line` is the line of the running script, counting from 1, and `$.stack` is the current stack trace ([the help page](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: both count lines in the file the code is in (the script, a file it included, a file `$.evalFile` ran, or the file of an earlier run of the named engine), and `$.stack` writes each frame as `name@file:line`, outermost first; code that `eval` or `Function` compiled keeps its own line in `[eval]`, and code whose place the engine no longer keeps is at line 0 of `[unknown]`. `crates/engine/src/tests/script_include.rs`, `crates/engine/src/tests/script_engines.rs`, `crates/js/src/extension_tests.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. @@ -804,11 +809,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/` and keeps that id for the session unless a library loaded later without a file shows that id itself, which moves it to the next free `loaded/ (N)`, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. + - Custom arrowheads under `--automation-read-root`: `stroke.arrowheads.list` gives `file` as `null` for an arrowheads file outside the read roots and names it by its file name in `error`, and `stroke.arrowheads.edit` and `stroke.arrowheads.reload` refuse such a file before anything is made, opened, brought to the front or read; inside the roots `edit` opens the file under the client's roots. `crates/engine/src/tests/arrowheads.rs` (`an_agent_outside_the_arrowheads_files_folder_sees_its_file_name_alone_and_cannot_reload_it`, `an_agent_edits_the_arrowheads_file_only_inside_its_read_roots`). - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - Effect parameter types: `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse an effect parameter of another type than the effect reads, for keys only the params text names too (the earlier 3D `perspective`, `lightAzimuth` and `lightElevation`, Scribble's `variation`, Outline Stroke's `width`, Gaussian Blur's `linear`), a Crop Marks `style` other than `roman` or `japanese`, and an installed plug-in effect's parameter of another type than its manifest gives it, in a `command.batch` step too, with the document and undo history unchanged; a replayed action still plays. `effect.setParams` checks the parameters against a Warp's new style, and refuses a `visible` that is not true or false, in a replayed action too. `crates/engine/src/tests/params.rs` (`an_effect_parameter_without_a_default_is_held_to_its_type`, `effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type`), `crates/engine/src/tests/plugins.rs` (`live_effect_plugins_reshape_editable_art_and_expand`), `crates/engine/src/cmd/effectcmd/tests.rs` (`set_params_refuses_a_visible_that_is_not_true_or_false`, `set_params_checks_the_effect_it_leaves`). - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). + - MCP window captures from an app that does not send the image back go through a file in a new folder inside the system's temporary folder, which only the user can open on Unix, instead of a fixed name in the shared folder; the folder is removed after the read, also when the capture or the read fails (`tests::conventions::an_old_app_captures_the_window_in_a_new_private_folder`). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. @@ -820,6 +827,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - With `Options::e4x` set, an E4X name standing alone (`@a`, `@ns::a`, `@[expr]`, `ns::a`, `*`) inside `with` or a filter predicate can be assigned, with `=` or a compound operator, incremented, decremented, deleted and used as a `for` or `for each` variable, as well as read. Each of these uses the nearest XML or XMLList value in scope that holds the name and evaluates the name once. When no XML value in scope holds the name, the read or write throws a `ReferenceError` before the right-hand side runs and creates no child or attribute, as ECMA-357 section 11.1 (https://ecma-international.org/publications-and-standards/standards/ecma-357/) specifies, so a filter such as `list.(@id == '1')` throws when an item has no `@id` and no outer XML value holds one. Tests: `crates/js/src/xml_tests.rs`. - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings. `xml.xpath(expression[, variables])` evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with all 13 axes and the 27 core functions, and returns the selected nodes as an XMLList in document order without repeats. Each property of `variables` binds the variable of its name, an XMLList runs the expression on each of its elements in turn, and a prefix in a name test resolves through the context element's in-scope namespaces. An expression that breaks the grammar gives a `SyntaxError`; an unknown function, a wrong argument count, an unbound prefix or variable, and a result that is not a node-set or that holds the root or a namespace node give a `TypeError`. `id()` selects nothing, and evaluation uses no native stack, takes fuel and counts against the heap limit. `` in an XML string gives a `SyntaxError`. An XML declaration at the start of an XML string, after optional white space, is checked against XML 1.0's grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of `1.` and digits, then an optional encoding name and an optional standalone `yes` or `no`, in that order; any other declaration, or one with a character XML does not allow, gives a `SyntaxError`. The parser takes fuel for the length of every name, white space run and declaration it scans, and for the text it searched when a quote, CDATA section, comment or processing instruction is not closed. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. + - Scripts: `inScopeNamespaces()`, `namespace()`, `namespaceDeclarations()`, the XPath `namespace` axis, prefixes in an XPath expression and the qualified names `name()` gives count the namespaces they collect from a node and its ancestors against the heap limit, so a node with many namespaces in scope stops the script at the heap limit instead of growing past it. `addNamespace()`, `setNamespace()`, `setName()` and `removeNamespace()` read an element's attributes in place instead of copying them, and `removeNamespace()` counts its walk of the tree; `child()` by index, `comments()`, `text()`, `elements()`, `processingInstructions()` and `normalize()` on an XMLList count their copy of its items; `replace()` counts its copy of the children it matched; and `name()` counts the prefixed name it builds. The `XML`, `XMLList`, `Namespace` and `QName` constructors, `isXMLName()`, `attribute()`, `namespace()`, `propertyIsEnumerable()`, `setName()`, `setLocalName()`, `addNamespace()`, `removeNamespace()` and `setNamespace()` count the copy they make when they flatten a long string argument, and `QName.prototype.toString()` counts its result before it builds it. Tests: `crates/js/src/xml_tests.rs`. - Scripts: assigning to a named child of an XML element (`x.b = v`) counts its list of the matching children against the heap limit, and assigning through an empty XMLList counts the chain of targets it climbs, so an assignment that would not fit stops before the tree changes. `copy()`, `toXMLString()`, `normalize()`, the `..` selector and inserting a node count the stack they keep for each open level of the tree, `copy()` and `toXMLString()` read an element's attributes in place instead of copying the list, `toXMLString()` also reads its namespace declarations in place, and `copy()` counts the declarations it copies before it copies them. `toXMLString()` counts the flat copy it makes of text or an attribute value built by joining strings. Deleting children or attributes by name, and assigning an attribute, remove the nodes in place instead of building a second list. `toXMLString()` counts the set of prefixes it passes looking up a namespace, and picks a new `ns` prefix in one pass over the declarations in scope. Inserting an XMLList, and assigning one to an item of an XMLList, count their copy of the list's items. Parsing counts the open elements, the text it gathers, each tag's attributes and the sets that check them, and the namespace declarations it makes. Comparing XML values, converting them to strings, and reading a name, a namespace or embedded text from a string built by joining strings count the flat copy they make; an attribute name read from a string counts its copy. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. @@ -827,11 +835,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › SVG Interactivity attaches JavaScript to the selected objects' events (`onclick`, `onmouseover` and 17 more) and links JavaScript files to the document (Link JavaScript Files…); SVG export writes the events as event attributes and the files as `