diff --git a/Cargo.lock b/Cargo.lock index 02e109b6b2..b42037f540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -986,12 +986,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7 [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "chrono", "dunce", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "flate2", "serde", @@ -1620,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3869,7 +3869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4391,7 +4391,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4769,7 +4769,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4828,7 +4828,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5687,7 +5687,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.6", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6326,6 +6326,7 @@ dependencies = [ "craft-window", "eframe", "egui", + "futures-lite", "image", "log", "muda", @@ -7516,7 +7517,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 29f43866c2..5bed166e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,18 +14,18 @@ repository = "https://github.com/storytold/vectorcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6", features = ["serde"] } craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280" } # craft-update: app-specific pretend versions apply to startup and manual checks. # Native Affinity documents (craft-libs#914). craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 57ab103590..8c4c4fffa2 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -246,6 +246,7 @@ fn with_stores(mut h: Headless) -> Headless { prefs_dir::attach_plugin_store(&mut h.session); prefs_dir::attach_pdf_preset_store(&mut h.session); prefs_dir::attach_custom_dictionary(&mut h.session); + prefs_dir::attach_arrowheads(&mut h.session); h } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 047c4530c7..55b9e344e7 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -107,6 +107,9 @@ 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 diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 85bcc2ddfe..03a9b8848c 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -1,10 +1,10 @@ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] use super::portal_save; -#[cfg(target_os = "linux")] -use super::tablet; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing}; +#[cfg(target_os = "linux")] +use super::{linux_screen_color, tablet}; #[cfg(target_os = "macos")] use super::{mac_save_format, mac_screen_color, mac_tablet}; @@ -354,8 +354,10 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); let (clip, clip_probe) = (parent.clone(), parent.clone()); let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] let sampled_ui = ui.clone(); + #[cfg(target_os = "linux")] + let sampled_parent = p1.clone(); Services { pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), @@ -408,6 +410,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Sample Color from Screen: the system's color sampler. #[cfg(target_os = "macos")] screen_color: Some(mac_screen_color::sampler(sampled_ui)), + #[cfg(target_os = "linux")] + screen_color: Some(linux_screen_color::sampler(sampled_ui, sampled_parent)), // File → Print: the system's printers and print queue. print: Some(Box::new(printing::SystemPrint)), ..Default::default() diff --git a/apps/vectorcraft/src/linux_screen_color.rs b/apps/vectorcraft/src/linux_screen_color.rs new file mode 100644 index 0000000000..0f46af19d8 --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color.rs @@ -0,0 +1,42 @@ +//! Linux: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the XDG desktop +//! portal's color sampler ([`portal`]). A thread of its own asks the portal and waits for the +//! person, so the UI keeps drawing; it sends the color to the UI and wakes the window. The dialog's +//! 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 { + Box::new(move || { + let (tx, rx) = std::sync::mpsc::channel(); + let (ui, window) = (ui.clone(), parent.clone()); + std::thread::Builder::new() + .name("screen-color".into()) + .spawn(move || { + // The thread names the window and holds it until the export that names it is dropped. + let (handle, export) = match window.as_deref() { + Some(window) => { + let (handle, export) = super::portal_save::exported_parent(window); + (handle, Some(export)) + } + None => (String::new(), None), + }; + let picked = portal::connect(zbus::blocking::connection::Builder::session()).and_then(|conn| portal::pick(&conn, &handle)); + drop(export); + drop(window); + let answer = picked.map_err(|failure| { + log::warn!("the portal's screen color sampler gave no color: {}", failure.detail()); + failure.message().to_string() + }); + // The UI may be gone (the app quitting): nothing waits for the color then. + let _ = tx.send(answer); + ui.request_repaint(); + }) + .map_err(|e| { + log::warn!("could not start the screen color sampler's thread: {e}"); + "the screen color sampler failed".to_string() + })?; + Ok(rx) + }) +} diff --git a/apps/vectorcraft/src/linux_screen_color/portal.rs b/apps/vectorcraft/src/linux_screen_color/portal.rs new file mode 100644 index 0000000000..b7518e1ce3 --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color/portal.rs @@ -0,0 +1,319 @@ +//! The XDG desktop portal's `Screenshot.PickColor` +//! (): +//! the call returns a request path at once, and the request's `Response` signal later carries the +//! color the person picked, as sRGB components from 0 to 1, or a code for a cancel or another end. + +use std::collections::HashMap; + +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::{OwnedValue, Value}; + +/// How long the portal has to return a request path for `PickColor`. VectorCraft's own limit, +/// 25 seconds as libdbus's default for a method call. The portal returns the path before it shows +/// the sampler, so the person's time with the sampler does not count. +const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); + +/// Why no color came back, as the status bar shows it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Failure { + /// No session bus, no portal, or a portal without the Screenshot interface's `PickColor`. + Unavailable(String), + /// The portal did not return a request path in time, or it left the bus before it answered. + NoAnswer(String), + /// The portal ended the request without a color, or its answer can't be read. + Failed(String), +} + +impl Failure { + /// The status bar's message (English; the status bar translates it). + pub(super) fn message(&self) -> &'static str { + match self { + Failure::Unavailable(_) => "this system has no screen color sampler", + Failure::NoAnswer(_) => "the screen color sampler did not answer", + Failure::Failed(_) => "the screen color sampler failed", + } + } + + /// What went wrong, for the log. + pub(super) fn detail(&self) -> &str { + match self { + Failure::Unavailable(d) | Failure::NoAnswer(d) | Failure::Failed(d) => d, + } + } +} + +/// D-Bus errors that mean the session has no portal, or none that answers `PickColor`. +const UNAVAILABLE: &[&str] = &[ + "org.freedesktop.DBus.Error.ServiceUnknown", + "org.freedesktop.DBus.Error.NameHasNoOwner", + "org.freedesktop.DBus.Error.UnknownMethod", + "org.freedesktop.DBus.Error.UnknownInterface", + "org.freedesktop.DBus.Error.UnknownObject", +]; + +/// The connection `builder` makes, with [`CALL_TIMEOUT`] for its method calls. +pub(super) fn connect(builder: zbus::Result>) -> Result { + builder.and_then(|b| b.method_timeout(CALL_TIMEOUT).build()).map_err(|e| Failure::Unavailable(e.to_string())) +} + +/// Shows the portal's color sampler over the window `parent` names and waits for the color, `None` +/// when the person canceled it. `conn`'s method timeout bounds the call; the wait for the person +/// after it has no bound, and ends when the portal answers or leaves the bus. +pub(super) fn pick(conn: &Connection, parent: &str) -> Result, Failure> { + const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + let token = super::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 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()))]); + let call = proxy.call_method("PickColor", &(parent, options)).map_err(|e| call_failure(&e))?; + let reply = replies.wait(&call).map_err(Failure::NoAnswer)?; + let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| Failure::Failed(e.to_string()))?; + answer(code, &results) +} + +/// The [`Failure`] for an error the `PickColor` call returned. zbus reports a call that outlives +/// the connection's method timeout as an `InputOutput` error of kind `TimedOut`. +fn call_failure(e: &zbus::Error) -> Failure { + match e { + zbus::Error::MethodError(name, ..) if UNAVAILABLE.contains(&name.as_str()) => Failure::Unavailable(e.to_string()), + zbus::Error::InputOutput(io) if io.kind() == std::io::ErrorKind::TimedOut => Failure::NoAnswer(e.to_string()), + _ => Failure::Failed(e.to_string()), + } +} + +/// The color in a `Response` with `code` and `results`: response 0 carries it, 1 is a cancel, and +/// any other code ends the request without one. The components are passed on as given; +/// `screen_color::sampled` clamps them to 0 to 1, as for the other systems' samplers. +fn answer(code: u32, results: &HashMap) -> Result, Failure> { + match code { + 0 => {} + 1 => return Ok(None), + _ => return Err(Failure::Failed(format!("the portal ended the request (response {code})"))), + } + let color = results.get("color").ok_or_else(|| Failure::Failed("no color in the portal's answer".into()))?; + // Matched field by field: zvariant's tuple conversion panics on a structure with fewer fields. + match &**color { + Value::Structure(s) => match s.fields() { + [Value::F64(r), Value::F64(g), Value::F64(b)] => Ok(Some([*r, *g, *b].map(|v| v as f32))), + _ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))), + }, + _ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn color(rgb: (f64, f64, f64)) -> HashMap { + HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())]) + } + + fn failed(r: Result) -> bool { + matches!(r, Err(Failure::Failed(_))) + } + + #[test] + fn a_picked_color_keeps_its_srgb_values() { + assert_eq!(answer(0, &color((0.25, 0.5, 0.75))), Ok(Some([0.25, 0.5, 0.75]))); + assert_eq!(answer(0, &color((0.0, 1.0, 0.0))), Ok(Some([0.0, 1.0, 0.0]))); + // Outside 0 to 1: passed on for `screen_color::sampled` to clamp. + assert_eq!(answer(0, &color((-0.5, 1.5, 1.0))), Ok(Some([-0.5, 1.5, 1.0]))); + } + + #[test] + fn a_cancel_needs_no_color() { + assert_eq!(answer(1, &HashMap::new()), Ok(None)); + assert_eq!(answer(1, &color((0.25, 0.5, 0.75))), Ok(None)); + } + + #[test] + fn other_response_codes_are_failures() { + for code in [2, 3, u32::MAX] { + assert!(failed(answer(code, &color((0.25, 0.5, 0.75)))), "{code}"); + } + } + + #[test] + fn a_success_without_a_valid_color_is_a_failure() { + assert!(failed(answer(0, &HashMap::new()))); + let wrong = HashMap::from([("color".into(), OwnedValue::from(1_u32))]); + assert!(failed(answer(0, &wrong))); + // Fewer fields, more fields, and fields of another type: an error, not a panic. + for wrong in [Value::from((0.5, 0.5)), Value::from((0.5, 0.5, 0.5, 0.5)), Value::from((1_u32, 0.5, 0.5)), Value::from(vec![0.5, 0.5, 0.5])] { + let wrong = HashMap::from([("color".into(), OwnedValue::try_from(wrong).unwrap())]); + assert!(failed(answer(0, &wrong))); + } + } + + #[test] + fn call_errors_say_whether_the_system_has_a_sampler() { + let reply = |name: &str| { + let call = zbus::Message::method_call("/org/freedesktop/portal/desktop", "PickColor").unwrap().build(&()).unwrap(); + let error = zbus::Message::error(&call.header(), name).unwrap().build(&("detail",)).unwrap(); + zbus::Error::from(error) + }; + for name in UNAVAILABLE { + assert!(matches!(call_failure(&reply(name)), Failure::Unavailable(_)), "{name}"); + } + assert!(matches!(call_failure(&reply("org.freedesktop.portal.Error.NotAllowed")), Failure::Failed(_))); + let timed_out = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::TimedOut.into())); + assert!(matches!(call_failure(&timed_out), Failure::NoAnswer(_))); + let broken = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::BrokenPipe.into())); + assert!(matches!(call_failure(&broken), Failure::Failed(_))); + let messages = [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())].map(|f| f.message()); + assert_eq!( + messages, + ["this system has no screen color sampler", "the screen color sampler did not answer", "the screen color sampler failed"] + ); + assert_eq!(call_failure(&broken).detail(), broken.to_string()); + } + + /// Every language that translates the sampler's other messages translates these too. The + /// i18n tests of `vectorcraft-ui-egui` do not read this crate's messages. + #[test] + fn the_messages_are_translated_where_the_sampler_is() { + use vectorcraft_ui_egui::i18n::{Lang, message}; + const OPEN: &str = "the screen color sampler is already open"; + let langs: Vec = Lang::all().filter(|&lang| message(lang, OPEN) != OPEN).collect(); + assert!(!langs.is_empty(), "no language translates {OPEN:?}"); + for lang in langs { + for failure in [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())] { + assert_ne!(message(lang, failure.message()), failure.message(), "{}", lang.code()); + } + } + } + + /// 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() + } + + /// The portal's name, owned on the private bus at `address` before any call, so the bus never + /// starts an installed portal in its place. + fn portal(address: &str) -> Connection { + zbus::blocking::connection::Builder::address(address).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap() + } + + /// Through a real D-Bus: an answer that comes before the call returns is kept; answers on + /// other request paths or from other connections are ignored; a cancel gives no color; and a + /// portal that leaves the bus while the person picks ends the wait. + #[test] + fn the_answer_is_the_portals_response_on_the_returned_path() { + use zbus::blocking::MessageIterator; + use zbus::zvariant::OwnedObjectPath; + + let Some((_daemon, address)) = private_bus() else { return }; + let address = address.as_str(); + let server = portal(address); + let impostor = zbus::blocking::connection::Builder::address(address).unwrap().build().unwrap(); + let incoming = MessageIterator::from(&server); + let worker = std::thread::spawn(move || { + let mut calls = Vec::new(); + for msg in incoming { + let msg = msg.unwrap(); + let header = msg.header(); + if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("PickColor") { + continue; + } + let (parent, options): (String, HashMap) = msg.body().deserialize().unwrap(); + let token = String::try_from(options.get("handle_token").unwrap().try_clone().unwrap()).unwrap(); + assert!(token.starts_with("vectorcraft_color_")); + assert!(token.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')); + assert!(!calls.iter().any(|(_, old)| old == &token)); + calls.push((parent, token)); + // A portal older than version 0.9 answers on a path of its own, not the token's. + let path = format!("/org/freedesktop/portal/desktop/request/independent/pick_{}", calls.len()); + let handle = OwnedObjectPath::try_from(path.as_str()).unwrap(); + if calls.len() == 4 { + server.reply(&header, &handle).unwrap(); + assert!(server.release_name("org.freedesktop.portal.Desktop").unwrap()); + break; + } + let to = header.sender().cloned().map(zbus::names::BusName::from); + // Another connection can't end the wait by claiming the portal left. + impostor + .emit_signal( + to.clone(), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameOwnerChanged", + &("org.freedesktop.portal.Desktop", server.unique_name().unwrap().as_str(), ""), + ) + .unwrap(); + let wrong = color((1.0, 0.0, 0.0)); + impostor.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap(); + let unrelated = "/org/freedesktop/portal/desktop/request/unrelated"; + server.emit_signal(to.clone(), unrelated, "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap(); + let code = if calls.len() == 2 { 1_u32 } else { 0_u32 }; + server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &color((0.25, 0.5, 0.75)))).unwrap(); + // The reply that names the path goes last: every answer above came before it. + server.reply(&header, &handle).unwrap(); + } + calls + }); + let client = client(address); + assert_eq!(pick(&client, "x11:12"), Ok(Some([0.25, 0.5, 0.75]))); + assert_eq!(pick(&client, "wayland:example"), Ok(None)); + assert_eq!(pick(&client, ""), Ok(Some([0.25, 0.5, 0.75]))); + assert!(matches!(pick(&client, ""), Err(Failure::NoAnswer(_)))); + let calls = worker.join().unwrap(); + assert_eq!(calls.iter().map(|(parent, _)| parent.as_str()).collect::>(), ["x11:12", "wayland:example", "", ""]); + } + + /// Through a real D-Bus: a portal without `PickColor` ends the request as a system without a + /// sampler, and the sampler's connection gives the call [`CALL_TIMEOUT`]. + #[test] + fn a_portal_without_pick_color_ends_the_request() { + let Some((_daemon, address)) = private_bus() else { return }; + let address = address.as_str(); + let server = portal(address); + let incoming = zbus::blocking::MessageIterator::from(&server); + let worker = std::thread::spawn(move || { + for msg in incoming { + let msg = msg.unwrap(); + let header = msg.header(); + if header.member().map(|m| m.as_str()) == Some("PickColor") { + server.reply_error(&header, "org.freedesktop.DBus.Error.UnknownMethod", &("no PickColor",)).unwrap(); + break; + } + } + }); + let client = client(address); + assert_eq!(client.method_timeout(), Some(CALL_TIMEOUT)); + let failure = pick(&client, ""); + assert!(matches!(failure, Err(Failure::Unavailable(_))), "{failure:?}"); + worker.join().unwrap(); + } +} diff --git a/apps/vectorcraft/src/linux_screen_color/request.rs b/apps/vectorcraft/src/linux_screen_color/request.rs new file mode 100644 index 0000000000..f442b840a3 --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color/request.rs @@ -0,0 +1,127 @@ +//! 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/mac_cursor.rs b/apps/vectorcraft/src/mac_cursor.rs index 85fea6c78a..69685a35b5 100644 --- a/apps/vectorcraft/src/mac_cursor.rs +++ b/apps/vectorcraft/src/mac_cursor.rs @@ -35,7 +35,6 @@ trait Backend { fn suspend(&mut self, window: &Self::Window); fn restore(&mut self, window: &Self::Window); fn build(&mut self, request: &Request) -> Option; - fn current(&self) -> Option; fn set(&mut self, cursor: &Self::Cursor); } @@ -73,9 +72,10 @@ impl State { && *key == request.key && *owned == window { - if self.backend.current().as_ref() != Some(cursor) { - self.backend.set(cursor); - } + // AppKit can show the system arrow when the pointer moves while + // `NSCursor.currentCursor` (the app's last set cursor) still reads as ours, so a + // comparison would keep the arrow. Setting the cursor again costs nothing. + self.backend.set(cursor); return; } self.release(); @@ -135,9 +135,6 @@ impl Backend for AppKit { fn build(&mut self, request: &Request) -> Option { build(&request.bitmap) } - fn current(&self) -> Option { - Some(NSCursor::currentCursor()) - } fn set(&mut self, cursor: &Self::Cursor) { cursor.set(); } diff --git a/apps/vectorcraft/src/mac_cursor/tests.rs b/apps/vectorcraft/src/mac_cursor/tests.rs index 648492c4b2..ad13a11336 100644 --- a/apps/vectorcraft/src/mac_cursor/tests.rs +++ b/apps/vectorcraft/src/mac_cursor/tests.rs @@ -6,7 +6,6 @@ use std::cell::Cell; struct Fake { window: Option, suspended: Vec, - current: Option, sets: usize, builds: usize, restores: Vec, @@ -27,17 +26,12 @@ impl Backend for Fake { fn restore(&mut self, w: &u8) { self.suspended.retain(|v| v != w); self.restores.push(*w); - self.current = None; } fn build(&mut self, r: &Request) -> Option { self.builds += 1; (!self.fail).then_some(r.key) } - fn current(&self) -> Option { - self.current - } - fn set(&mut self, cursor: &Key) { - self.current = Some(*cursor); + fn set(&mut self, _: &Key) { self.sets += 1; } } @@ -66,19 +60,16 @@ fn deliver(state: &mut State, request: Option) { } #[test] -fn mac_cursor_ownership_deduplicates_reasserts_and_restores() { +fn mac_cursor_ownership_reasserts_every_pass_and_restores() { let req = request(1.0); let mut state = fake_state(); deliver(&mut state, Some(req.clone())); assert_eq!(state.backend.suspended, [1]); assert_eq!((state.backend.builds, state.backend.sets), (1, 1)); + // The pointer moves and AppKit shows the system arrow, but the app's current cursor + // still reads as ours. The next pass sets it again without rebuilding or re-suspending. deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 1); - // Simulate a platform fallback replacing the cursor after finalization. The next pass - // reasserts; this proves the protocol, not AppKit timing or absence of transient flicker. - state.backend.current = None; - deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 2); + assert_eq!((state.backend.builds, state.backend.sets, state.backend.suspended.len()), (1, 2, 1)); deliver(&mut state, None); assert_eq!(state.backend.restores, [1]); deliver(&mut state, Some(req.clone())); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 33e03b1f72..f59d195cd5 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -32,6 +32,8 @@ mod cursor_theme; mod gpu; mod hang_watch; #[cfg(target_os = "linux")] +mod linux_screen_color; +#[cfg(target_os = "linux")] mod linux_stylus; mod logging; #[cfg(all(target_os = "macos", feature = "wgpu"))] @@ -485,6 +487,8 @@ fn main() -> std::process::ExitCode { prefs_dir::attach_pdf_preset_store(&mut app.session); // The custom dictionary's words, kept in their own file next to the preferences. prefs_dir::attach_custom_dictionary(&mut app.session); + // The custom arrowheads, kept in their own file next to the preferences. + prefs_dir::attach_arrowheads(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). let winit_window = cc.winit_window().cloned(); if let Some(w) = &winit_window { diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 930938e03f..a9bf5d1009 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -242,6 +242,13 @@ fn parent(window: &winit::window::Window) -> (String, Option) { } } +/// [`parent`] for another dialog of the portal (the screen color sampler, `linux_screen_color`): +/// the identifier stays valid until the second value is dropped. +#[cfg(target_os = "linux")] +pub(crate) fn exported_parent(window: &winit::window::Window) -> (String, impl Send + 'static) { + parent(window) +} + /// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off /// Linux). #[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 96e09f5e78..b4c0a41103 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -81,6 +81,14 @@ pub fn attach_custom_dictionary(session: &mut vectorcraft_engine::Session) { } } +/// Keep the custom arrowheads (Stroke panel menu › Edit Arrowheads…) in the `Arrowheads.vectorcraft` +/// file next to the settings, and list the heads it holds. +pub fn attach_arrowheads(session: &mut vectorcraft_engine::Session) { + if let Some(path) = store_dir(vectorcraft_engine::cmd::arrowheads::FILE_NAME) { + session.attach_arrowheads_file(path.to_string_lossy().to_string()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 448b2c2ba0..f25615c589 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -370,11 +370,14 @@ pub struct ArtBrush { pub scale: ArtScale, /// Width in percent. pub width: f64, - /// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from - /// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest. + /// How the width varies along a stroke: `None` (Fixed) always `width`; a mode that follows the + /// pen goes from `width` to [`Self::width_max`] over the ranges [`Variation`] gives: Pressure + /// from the lightest pressure to the heaviest, Tilt from the pen lying flat to upright, Bearing + /// and Rotation from −180° to 180° (half way at 0°). #[serde(skip_serializing_if = "Option::is_none")] pub width_mode: Option, - /// The width at the heaviest pen pressure (%), for Pressure (`width` without one). + /// The second width (%) of a mode that follows the pen: at the heaviest pressure, upright, or + /// at 180° (`width` without one). #[serde(skip_serializing_if = "Option::is_none")] pub width_max: Option, pub flip_along: bool, @@ -388,11 +391,11 @@ impl ArtBrush { /// The range of the width (%). pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0); - /// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the - /// width follows the pressure. + /// The widths (%) where the pen reads 0 and 1 for the width's mode (the lightest and the + /// heaviest pressure, for Pressure): `width` for both unless the width follows the pen. pub fn width_range(&self) -> (f64, f64) { match self.width_mode { - Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)), + Some(m) if m.follows_pen() => (self.width, self.width_max.unwrap_or(self.width)), _ => (self.width, self.width), } } @@ -713,8 +716,8 @@ impl Brush { } BrushKind::Art(a) => { a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0); - // The width follows the pen's pressure or stays fixed. - if a.width_mode != Some(Variation::Pressure) { + // The width follows the pen (its pressure, tilt, bearing or rotation) or stays fixed. + if !a.width_mode.is_some_and(Variation::follows_pen) { a.width_mode = None; } a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width)); @@ -806,7 +809,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok let mut out = match &brush.kind { BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name), BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name), - BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()), + BrushKind::Art(a) => warp::art(a, bp, w, stroke), BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name), BrushKind::Pattern(p) => warp::pattern(p, bp, w), }; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 11c83d97bc..affd2fa6b7 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1371,6 +1371,62 @@ fn art_brush_width_follows_the_pressure() { assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}"); } +/// Art Brush Options: Width on Tilt goes from Width with the pen lying flat to the second width +/// upright, and on Bearing or Rotation from Width at −180° to the second width at 180°, along the +/// stroke as the pen recorded it; each mode reads only its own input. +#[test] +fn art_brush_width_follows_tilt_bearing_and_rotation() { + let brush = |mode: Variation| { + let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch); + if let BrushKind::Art(a) = &mut b.kind { + (a.width, a.width_mode, a.width_max) = (50.0, Some(mode), Some(300.0)); + } + b + }; + let bp = line(0.0, 0.0, 100.0, 0.0); + let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st); + // Tilt, upright at the start (300 %: 6 pt either side of 4 pt art) to flat at the end (50 %: 1 pt). + let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) }; + let out = draw(&brush(Variation::Tilt), &upright_to_flat); + assert!(near(half_width(&out, 0.0, 1.0), 6.0, 0.1), "{}", half_width(&out, 0.0, 1.0)); + assert!(near(half_width(&out, 99.0, 100.0), 1.0, 0.1), "{}", half_width(&out, 99.0, 100.0)); + assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0)); + // Bearing and Rotation: half way at 0°, a quarter of the way at −90°, three quarters at 90°. + for (mode, angle, half) in [ + (Variation::Bearing, 0.0, 3.5), + (Variation::Bearing, -90.0, 2.25), + (Variation::Bearing, 90.0, 4.75), + (Variation::Rotation, -90.0, 2.25), + (Variation::Rotation, 90.0, 4.75), + ] { + let st = if mode == Variation::Bearing { posed(Some(0.5), Some(angle), None) } else { posed(None, None, Some(angle)) }; + let h = bounds(&draw(&brush(mode), &st)).height(); + assert!(near(h, 2.0 * half, 1e-6), "{mode:?} {angle}: {h}"); + } + // Each mode reads its own input: without it (the pressure, or another of the pen's values + // recorded instead), the art is half way between the two widths. + let others = [ + (Variation::Tilt, posed(None, Some(90.0), Some(90.0))), + (Variation::Tilt, pressed(vec![(0.0, 0.0), (1.0, 1.0)])), + (Variation::Bearing, posed(Some(0.0), None, Some(90.0))), + (Variation::Rotation, posed(Some(0.0), Some(90.0), None)), + (Variation::Pressure, posed(Some(0.0), Some(90.0), Some(90.0))), + ]; + for (mode, st) in others { + let h = bounds(&draw(&brush(mode), &st)).height(); + assert!(near(h, 7.0, 1e-6), "{mode:?}: {h}"); + } + // The pen's modes are kept when a definition is read, and Random is read as Fixed. + let lib = parse_library(&serde_json::json!([ + {"name": "t", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "tilt", "width_max": 300.0}, + {"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "bearing", "width_max": 300.0}, + {"name": "r", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "rotation", "width_max": 300.0}, + {"name": "x", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 300.0}, + ])); + let modes: Vec<_> = lib.iter().map(|b| if let BrushKind::Art(a) = &b.kind { a.width_mode } else { None }).collect(); + assert_eq!(modes, [Some(Variation::Tilt), Some(Variation::Bearing), Some(Variation::Rotation), None]); +} + /// Definitions from files and commands are kept within their ranges. #[test] fn sanitize_keeps_values_within_their_ranges() { diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index b34a1cf31e..c72e1c33f8 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use kurbo::ParamCurveNearest; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2}; use crate::track::{Rng, Track, normal, seed_of, tracks}; @@ -503,9 +503,10 @@ fn art_on_run( bend_widened(t, art, b, along, cross, widen) } -/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as -/// wide as the pressure recorded at each point of the stroke gives (half way without a recording). -pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec { +/// An art brush's art along `bp`. With a width that follows the pen (its pressure, tilt, bearing +/// or rotation), the art is as wide as what `stroke` recorded at each point gives (half way +/// without a recording). +pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer) -> Vec { let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] }; let (light, heavy) = a.width_range(); let width = light.max(heavy); @@ -522,7 +523,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre } // A run past the start of a closed track wraps round to it. let at = if s > l { s - l } else { s }; - let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0); + let p = a.width_mode.and_then(|m| PenAt::of(stroke, at / l).control(m)).unwrap_or(0.5); (light + (heavy - light) * p) / width }; let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] }; diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 3e20c69a98..6ab80c8ba0 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -187,6 +187,9 @@ pub struct HpglOutput { pub pens: Vec<[u8; 3]>, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: sorting the paths or drawing the inner paths first was applied only + /// in part. + pub setting_notes: Vec, } /// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are @@ -280,10 +283,11 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } + let mut setting_notes = vec![]; if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut setting_notes); } } if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { @@ -293,7 +297,7 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), }; - out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + out.push(HpglOutput { bytes, pens: pens.clone(), warnings, setting_notes }); } Ok(out) } @@ -366,20 +370,20 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { /// for a closed path any of its points (where it then starts); without, the first in paint order. /// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. /// Once `work` is spent, the paths left keep paint order, each after the paths inside it with -/// `inside_first`, with a warning. Returns where the pen stops. +/// `inside_first`, with a note in `notes`. Returns where the pen stops. fn reorder( paths: &mut Vec>, mut at: (i64, i64), work: &mut u64, nearest: bool, inside_first: bool, - warnings: &mut Vec, + notes: &mut Vec, ) -> (i64, i64) { let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); // The paths each path lies inside, and how many paths inside each are still to draw. let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); if inside_first && outer.is_none() { - warn(warnings, UNNESTED); + warn(notes, UNNESTED); } let mut waiting = vec![0usize; paths.len()]; for o in outer.iter().flatten().flatten() { @@ -430,11 +434,11 @@ fn reorder( if *work == 0 || best.0 >= left.len() { match &outer { Some(outer) => { - warn(warnings, UNSORTED_INSIDE); + warn(notes, UNSORTED_INSIDE); inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); } None => { - warn(warnings, UNSORTED); + warn(notes, UNSORTED); paths.extend(left.into_iter().map(|(_, p, _)| p)); } } diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index c29ef67532..9cb4f7d727 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -852,7 +852,8 @@ fn sorting_measures_a_closed_path_by_its_nearest_point() { fn sorting_stops_at_its_budget_and_keeps_every_path() { let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); - assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [UNSORTED]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); assert_eq!(text(&out).matches("PD").count(), 600); } @@ -944,18 +945,18 @@ fn inside_first_orders_each_pens_paths() { } /// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in -/// paint order, with a warning. +/// paint order, with a note on the option. #[test] fn inside_first_past_its_budget_draws_in_paint_order() { let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED]); + assert_eq!(out.setting_notes, [UNNESTED]); let l = lefts(&out); assert_eq!(l.len(), 100); assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); - // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + // With `sort` too, the budget is spent before sorting starts: paint order, with both notes. let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(out.setting_notes, [UNNESTED, UNSORTED]); assert_eq!(lefts(&out), l); } @@ -969,7 +970,7 @@ fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { v }); let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNSORTED_INSIDE]); + assert_eq!(out.setting_notes, [UNSORTED_INSIDE]); let l = lefts(&out); assert_eq!(l.len(), 602); assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 0ef28a4aaa..1443829806 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -410,6 +410,9 @@ pub struct DxfOutput { pub images: Vec, /// What was approximated or left out. pub warnings: Vec, + /// What was left out because an option turns it off (Include Placed Images or Include Fill + /// Hatches off). + pub setting_notes: Vec, } /// The smallest and largest `scale` (drawing units per unit). diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..0379fc4ea5 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> { /// Image keys → index into `images` (`None`: left out). by_key: HashMap>, warnings: Vec, + /// What was left out because an option asked for it ([`DxfOutput::setting_notes`]). + setting_notes: Vec, brushes: Option>, extents: Option<[f64; 4]>, /// The opacity of the containers around the current object. @@ -554,6 +556,7 @@ impl<'a> Scene<'a> { files: vec![], by_key: HashMap::new(), warnings: vec![], + setting_notes: vec![], brushes: None, extents: None, opacity: 1.0, @@ -588,15 +591,24 @@ impl<'a> Scene<'a> { metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters), next: self.next, }; - DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings } + DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings, setting_notes: self.setting_notes } } + /// Note `w`, once, about something of the document that the drawing leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about something left out because an option turns it off. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn handle(&mut self) -> Handle { let h = Handle(self.next); self.next = self.next.saturating_add(1); @@ -930,7 +942,7 @@ impl<'a> Scene<'a> { // The outlines close as the fill does, so an open path's fill keeps its closing edge. self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { - self.warn(HATCHES_OFF); + self.note_setting(HATCHES_OFF); return; } let h = self.handle(); @@ -1321,7 +1333,7 @@ impl<'a> Scene<'a> { return; } if !self.o.include_images { - self.warn(IMAGES_OFF); + self.note_setting(IMAGES_OFF); return; } let Some(i) = self.image_def(im) else { return }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 9d2a519e1d..96c037a4a7 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -881,12 +881,13 @@ fn fills_without_hatches_keep_their_outlines() { let d = shapes_doc(); let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); - assert_eq!(out.warnings, [HATCHES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [HATCHES_OFF]); let e = entities(&out); assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); // Hatches stay by default, with no note. let on = export(&d, &opts(&d)).unwrap(); - assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && on.setting_notes.is_empty()); // The outlines are the ones written beside hatches, also for overlapping subpaths, which are // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. let compound = doc_with(|d| { @@ -916,13 +917,14 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); // Nothing filled: no note. let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + let none = export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap(); + assert!(none.warnings.is_empty() && none.setting_notes.is_empty()); // R12 has no hatches anyway: the same outlines, and its own note alone. let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); let (off, on) = (r12(false), r12(true)); assert_eq!(off.bytes, on.bytes); assert_eq!(off.warnings, on.warnings); - assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(off.setting_notes.is_empty()); } #[test] @@ -957,15 +959,18 @@ fn images_can_be_left_out_with_their_files() { let p = pairs(&out.bytes); assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); - assert_eq!(out.warnings, [IMAGES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [IMAGES_OFF]); // An image that can't be decoded is not read when images are left out. let mut broken = d.clone(); broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); - assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + let left_out = export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap(); + assert!(left_out.warnings.is_empty(), "{:?}", left_out.warnings); + assert_eq!(left_out.setting_notes, [IMAGES_OFF]); assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); // R12 has no images anyway: its own note alone. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); - assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && r12.setting_notes.is_empty()); } #[test] @@ -983,7 +988,7 @@ fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_b let cnc = run(DxfPreset::Cnc.options()); assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); assert!(cnc.images.is_empty()); - assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + assert!(cnc.setting_notes.iter().any(|w| w == IMAGES_OFF) && cnc.setting_notes.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.setting_notes); let custom = DxfPreset::Custom.options(); assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); } diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c5803e3224..e49478eab7 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; use crate::Color; use crate::freeform::Freeform; +mod spread; +pub use spread::{MAX_SPREAD_LEVEL, MAX_SPREAD_RUNS, MAX_SPREAD_STOPS, Padded}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum GradientKind { #[default] @@ -94,6 +97,82 @@ impl GradientMethod { } } +/// 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. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)] +pub enum GradientSpread { + /// The first and last stops' colors carry on past the ends, as gradients always did. + #[default] + Pad, + /// The stops start again at each end. + Repeat, + /// The stops run back the other way at each end, so every other run is mirrored. + Reflect, +} + +impl GradientSpread { + /// Every spread, in the order the Gradient panel lists them. + pub const ALL: [GradientSpread; 3] = [GradientSpread::Pad, GradientSpread::Repeat, GradientSpread::Reflect]; + /// Parse a spread name (`pad`, `repeat`, `reflect`; any case). + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + Self::Pad => "Pad", + Self::Repeat => "Repeat", + Self::Reflect => "Reflect", + } + } + pub fn is_pad(&self) -> bool { + *self == Self::Pad + } +} + +impl<'de> Deserialize<'de> for GradientSpread { + /// A spread name; a file's unknown name or other value reads as Pad. + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Name(String), + Other(serde::de::IgnoredAny), + } + Ok(match Raw::deserialize(d)? { + Raw::Name(s) => Self::parse(&s).unwrap_or_default(), + Raw::Other(_) => Self::Pad, + }) + } +} + +/// Most times a gradient's stops run along its vector ([`Gradient::repeats`]): VectorCraft's own +/// limit. +pub const MAX_REPEATS: u32 = 100; + +fn one_repeat() -> u32 { + 1 +} + +fn is_one_repeat(n: &u32) -> bool { + *n == 1 +} + +/// A file's repeat count: a number, rounded and kept within 1 to [`MAX_REPEATS`]; anything else +/// reads as 1. +fn lenient_repeats<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Number(f64), + Other(serde::de::IgnoredAny), + } + Ok(match Raw::deserialize(d)? { + Raw::Number(n) if n.is_finite() => n.round().clamp(1.0, f64::from(MAX_REPEATS)) as u32, + _ => 1, + }) +} + /// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring /// expanded stops may stray from the perceptual mix: under half a colour level. const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; @@ -259,6 +338,15 @@ pub struct Gradient { /// How the colours between the stops are mixed (Classic unless set; written only when not). #[serde(default, skip_serializing_if = "GradientMethod::is_classic")] pub method: GradientMethod, + /// What it paints past the ends of its stops (Pad unless set; written only when not). A + /// freeform gradient has no ends and ignores it. + #[serde(default, skip_serializing_if = "GradientSpread::is_pad")] + pub spread: GradientSpread, + /// How many times the stops run along the vector with Repeat and Reflect, 1 to + /// [`MAX_REPEATS`] (use [`Self::repeat_count`]: a value set in code may be out of range); Pad + /// ignores it. Written only when it is not 1. + #[serde(default = "one_repeat", skip_serializing_if = "is_one_repeat", deserialize_with = "lenient_repeats")] + pub repeats: u32, } impl Default for Gradient { @@ -271,7 +359,55 @@ impl Default for Gradient { impl Gradient { /// A gradient of `kind` through `stops`, with the default options. pub fn new(kind: GradientKind, stops: Vec) -> Self { - Self { kind, stops, dither: false, method: GradientMethod::Classic } + Self { kind, stops, dither: false, method: GradientMethod::Classic, spread: GradientSpread::Pad, repeats: 1 } + } + /// [`Self::repeats`] kept within 1 to [`MAX_REPEATS`]. + pub fn repeat_count(&self) -> u32 { + self.repeats.clamp(1, MAX_REPEATS) + } + /// Does it paint something other than its end colors past the ends of its stops, or run its + /// stops more than once: a linear, radial, conic or diamond gradient that repeats or reflects? + pub fn is_spread(&self) -> bool { + !self.spread.is_pad() && self.kind != GradientKind::Freeform + } + /// The spread and count as the gradient paints them: Pad and 1 for one that pads (whatever + /// count it keeps) and for a freeform one, else its spread and [`Self::repeat_count`]. Equal + /// for two gradients whose spreads paint alike. + pub fn painted_spread(&self) -> (GradientSpread, u32) { + if self.is_spread() { (self.spread, self.repeat_count()) } else { (GradientSpread::Pad, 1) } + } + /// The position (0..1) along the stops that gradient parameter `t` (0 at the start of the + /// vector, 1 at its end; [`GradientGeom::param_at`]) samples: `t` itself with Pad (the stops + /// clamp it), else `t` × [`Self::repeat_count`] wrapped back into 0..1, every other run + /// mirrored with Reflect. A `t` that is not finite is returned as it is. + pub fn spread_param(&self, t: f64) -> f64 { + self.spreader()(t) + } + /// How many wedges a conic gradient is written as ([`GradientGeom::conic_wedges`]) where + /// `per_turn` would do for stops that run once around: that many for each time the stops run + /// around, at most [`MAX_CONIC_WEDGES`]. Sample each wedge at [`Self::spread_param`] of its + /// turn. + pub fn conic_wedge_count(&self, per_turn: usize) -> usize { + let runs = if self.is_spread() { self.repeat_count() as usize } else { 1 }; + per_turn.saturating_mul(runs).clamp(1, MAX_CONIC_WEDGES) + } + /// [`Self::spread_param`] as a function that holds no borrow of the gradient. + pub fn spreader(&self) -> impl Fn(f64) -> f64 + Copy + use<> { + let spread = if self.is_spread() { self.spread } else { GradientSpread::Pad }; + let n = f64::from(self.repeat_count()); + move |t: f64| { + if !t.is_finite() { + return t; + } + match spread { + GradientSpread::Pad => t, + GradientSpread::Repeat => (t * n).rem_euclid(1.0), + GradientSpread::Reflect => { + let w = (t * n).rem_euclid(2.0); + if w > 1.0 { 2.0 - w } else { w } + } + } + } } /// Colour and opacity at `t` (honours midpoints and the method). pub fn sample(&self, t: f32) -> (Color, f32) { @@ -544,10 +680,18 @@ impl Gradient { GradientStop { opacity, ..GradientStop::new(offset, color) } }; let d = to - from; - let mut stops = vec![stop(0.0, from)]; + let expanded = self.expanded_stops(); + // Where stops share the offset `from` (a sharp step, as where the runs of a repeating + // gradient meet), the piece starts with the color after the step; sampling gives the one + // before it. + let first = match expanded.iter().rev().find(|(t, ..)| *t == from) { + Some(&(_, color, opacity)) if d > 1e-6 => GradientStop { opacity, ..GradientStop::new(0.0, color) }, + _ => stop(0.0, from), + }; + let mut stops = vec![first]; if d > 1e-6 { stops.extend( - self.expanded_stops() + expanded .into_iter() .filter(|(t, ..)| *t > from && *t < to) .map(|(t, color, opacity)| GradientStop { opacity, ..GradientStop::new((t - from) / d, color) }), @@ -954,6 +1098,35 @@ impl GradientGeom { self.set_focal(Some(m * q)); } } + /// The placement whose level `t` is this one's level `k`·`t` (`k` > 0), for a linear, radial or + /// diamond gradient: the vector `k` times as long from the same start, and for an off-center + /// radial the extent ellipse scaled `k` times about the focal point, which stays. A conic keeps + /// its placement (its level is a turn). + pub fn levels_scaled(&self, kind: GradientKind, k: f64) -> GradientGeom { + if kind == GradientKind::Conic || !(k.is_finite() && k > 0.0) { + return *self; + } + let u = self.end - self.start; + let mut out = *self; + if kind == GradientKind::Radial + && let (Some(f), Some(frame)) = (self.focal, self.unit_frame()) + { + // Level t of an off-center radial is the extent ellipse scaled by t about the line + // from the focal point to the center: the new center sits on that line, `k` times as + // far from the focal point. + let f = (frame.inverse() * f).to_vec2(); + out.start = frame * (f * (1.0 - k)).to_point(); + } + out.end = out.start + u * k; + out + } + + /// The placement over which one run of the stops of a gradient that repeats `n` times along + /// its vector lies ([`Self::levels_scaled`] by 1/`n`). + pub fn period(&self, kind: GradientKind, n: u32) -> GradientGeom { + self.levels_scaled(kind, 1.0 / f64::from(n.max(1))) + } + pub fn angle_deg(&self) -> f64 { let v = self.end - self.start; (-v.y).atan2(v.x).to_degrees() @@ -1148,12 +1321,13 @@ impl GradientPaint { inks: bool, ) -> impl Iterator + use { let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither); + let spread = self.gradient.spreader(); let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1))); let (x0, y0) = (area.x0, area.y0); let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end)); down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| { let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch); - let c = ramp.at(geom.param_at(kind, p) as f32); + let c = ramp.at(spread(geom.param_at(kind, p)) as f32); std::array::from_fn(|k| { if k + 1 == N { return (c[k].clamp(0.0, 1.0) * 255.0).round() as u8; diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs new file mode 100644 index 0000000000..8f9933c40c --- /dev/null +++ b/crates/color/src/gradient/spread.rs @@ -0,0 +1,374 @@ +//! A repeating or reflecting gradient as one that pads, for formats whose gradients only pad +//! past their ends: the stops repeated (every other run mirrored, for Reflect) as many times as +//! the painted area needs, and the vector stretched over those runs. + +use kurbo::{Point, Rect, Vec2}; + +use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop}; +use crate::Color; + +/// Most runs of the stops [`Gradient::padded_over`] writes. +pub const MAX_SPREAD_RUNS: usize = 256; + +/// Most stops [`Gradient::padded_over`] writes, all runs together. +pub const MAX_SPREAD_STOPS: usize = 16384; + +/// The farthest level from its center a radial or diamond gradient's runs are written out to by +/// [`Gradient::padded_over`] (its stops' offsets, single-precision fractions of it, stay apart), +/// nearer for stops closer together than a run ([`MIN_OFFSET_GAP`]). +pub const MAX_SPREAD_LEVEL: f64 = 65536.0; + +/// The least difference between two stops' offsets [`Gradient::padded_over`] writes as +/// different: sixteen times single precision's step just below 1. +const MIN_OFFSET_GAP: f64 = 1e-6; + +/// A repeating or reflecting gradient written as one that pads ([`Gradient::padded_over`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Padded { + /// The gradient with its stops repeated, padding past its ends. + pub gradient: Gradient, + /// Where those runs lie. + pub geom: GradientGeom, + /// Where along `geom` (0 to 1) the first run written starts: 0 for a linear gradient, the + /// first run's level over the last one's outer level for a radial or diamond one. + pub first: f64, + /// How many runs are written, evenly spaced from [`Self::first`] to 1. + pub runs: usize, + /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed, + /// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. + pub capped: bool, +} + +impl Gradient { + /// A linear, radial or diamond gradient that repeats or reflects, placed at `geom`, as a + /// gradient that pads and paints `area` the same way: its stops repeated, each run's + /// placement [`GradientGeom::period`] of `geom`, as many runs as the area reaches (a radial or + /// diamond from its first level, 0, the first stop's color inside the area's innermost run). + /// `None` for a gradient that pads, a conic or + /// freeform one, one without stops, and a placement whose levels at the corners are not + /// finite. + pub fn padded_over(&self, geom: &GradientGeom, area: Rect) -> Option { + self.padded_within(geom, area, MAX_SPREAD_STOPS) + } + + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs + /// written need more stops than that, each run leaves out the stops that change its colors + /// least ([`fit_run`]), so every run is still written. + pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option { + let kind = self.kind; + if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { + return None; + } + 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)); + if ts.iter().any(|t| !t.is_finite()) { + return None; + } + let centered = kind.is_centered(); + // The level's range over the area. A linear gradient's extremes lie at corners. Radial and + // diamond levels are nested convex rings from level 0: the largest at a corner, the + // smallest 0 when the area holds the rings' center, else on its edge. + let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; + let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let most = MAX_SPREAD_RUNS.min(budget / 2).max(1); + let capped = hi - lo > most as f64; + // Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear) + // those around the vector's start where the area reaches it, else the area's runs nearest + // to it. + let lo = match capped { + true if !centered => (-((most / 2) as f64)).clamp(lo, hi - most as f64), + _ => lo, + }; + let n = (hi - lo).min(most as f64).max(1.0); + let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method); + // A radial or diamond writes its runs among the levels from 0, which its stops' offsets + // (single-precision fractions of the outermost level) tell apart only so far out, the + // nearer the closer its stops lie: past that, the runs end there and the gradient pads. + let gap = one.windows(2).map(|w| f64::from(w[1].offset - w[0].offset)).filter(|d| *d > 0.0).fold(1.0, f64::min); + // A whole level, so the runs keep their places (and Reflect its mirrored ones). + let farthest = MAX_SPREAD_LEVEL.min(gap / MIN_OFFSET_GAP).floor().max(n); + let far = centered && lo + n > farthest; + let (lo, capped) = if far { ((farthest - n).max(0.0), true) } else { (lo, capped) }; + // `lo` is a whole number, possibly past what an integer holds: its parity in floating point. + let first_odd = lo.rem_euclid(2.0) >= 1.0; + let (stops, geom) = if centered { + // The levels from 0, the runs from level `lo` (the first stop's color inside it). + (self.runs(&one, first_odd, lo, n as usize, lo + n), period.levels_scaled(kind, lo + n)) + } else { + let u = period.end - period.start; + ( + self.runs(&one, first_odd, 0.0, n as usize, n), + GradientGeom { start: period.start + u * lo, end: period.start + u * (lo + n), ..period }, + ) + }; + let gradient = Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }; + let first = if centered { lo / (lo + n) } else { 0.0 }; + Some(Padded { gradient, geom, first, runs: n as usize, capped }) + } + + /// The gradient as it runs from the start of its vector (0) to its end (1), as one that + /// pads: a gradient that repeats or reflects with its stops repeated [`Self::repeat_count`] + /// times between 0 and 1 (every other run mirrored, for Reflect), for what only shows that + /// stretch (a gradient along or across a stroke, a swatch's preview). Others as they are. + pub fn unrolled(&self) -> std::borrow::Cow<'_, Gradient> { + self.unrolled_within(MAX_SPREAD_STOPS) + } + + /// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops + /// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with + /// fewer than two stops for each run do the runs past those that fit pad with the last one. + pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if !self.is_spread() { + return Cow::Borrowed(self); + } + // Without stops there is nothing to repeat: it pads all the same. + if self.stops.is_empty() { + return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); + } + let n = self.repeat_count() as usize; + let budget = max_stops.min(MAX_SPREAD_STOPS); + let kept = n.min(budget / 2).max(1); + let one = fit_run(whole_run(&self.stops), budget / kept, self.method); + let stops = self.runs(&one, false, 0.0, kept, n as f64); + Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }) + } + + /// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two): + /// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that + /// pads with more stops than that without the stops that change its colors least + /// ([`fit_run`]). Others as they are. + pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if self.is_spread() { + return self.unrolled_within(max_stops); + } + if self.stops.len() <= max_stops.max(2) { + return Cow::Borrowed(self); + } + Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() }) + } + + /// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as + /// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect), + /// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and + /// one without stops. + pub fn cycle(&self) -> Option<(Gradient, u32)> { + if !self.is_spread() || self.stops.is_empty() { + return None; + } + 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)) + } + + /// `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). + fn runs(&self, one: &[GradientStop], first_odd: bool, start: f64, n: usize, of: f64) -> Vec { + let mirrored_run = (self.spread == GradientSpread::Reflect).then(|| mirror(one)); + let mut stops = Vec::with_capacity(one.len() * n); + for j in 0..n { + let run = match &mirrored_run { + Some(m) if (j % 2 == 1) != first_odd => m.as_slice(), + _ => one, + }; + stops.extend(run.iter().map(|s| GradientStop { offset: ((start + j as f64 + f64::from(s.offset)) / of) as f32, ..s.clone() })); + } + stops + } +} + +/// The smallest level of radial or diamond gradient `geom` over `area`, less a run (for rounding): +/// 0 when the area holds the rings' center (a radial's focal point, a diamond's center), else the +/// smallest along its sides, worked out in the gradient's unit frame from the sides' ends rather +/// than from samples along them, so a long side does not hide a run near it. 0 for a placement +/// without a frame. +fn inner_level(geom: &GradientGeom, kind: GradientKind, area: Rect) -> f64 { + let center = if kind == GradientKind::Radial { geom.focal_point() } else { geom.start }; + if area.contains(center) { + return 0.0; + } + let frame = if kind == GradientKind::Diamond { geom.diamond_frame() } else { geom.unit_frame() }; + let Some(to_unit) = frame.map(|m| m.inverse()) else { return 0.0 }; + let focal = geom.focal.filter(|_| kind == GradientKind::Radial).map(|f| (to_unit * f).to_vec2()); + 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)] + .map(|p| (to_unit * p).to_vec2()); + let mut least = f64::INFINITY; + for (&a, &b) in corners.iter().zip(corners.iter().cycle().skip(1)) { + let side = if kind == GradientKind::Diamond { diamond_least(a, b) } else { radial_least(a, b, focal) }; + least = least.min(side); + } + if least.is_finite() { (least - 1.0).max(0.0) } else { 0.0 } +} + +/// The smallest diamond level, |x| + |y| in its unit frame, on the segment from `a` to `b`: the +/// level is linear between the points where the segment crosses an axis, so the smallest is at +/// an end or at one of those crossings. +fn diamond_least(a: Vec2, b: Vec2) -> f64 { + let d = b - a; + let crossings = [(d.x != 0.0).then(|| -a.x / d.x), (d.y != 0.0).then(|| -a.y / d.y)]; + [Some(0.0), Some(1.0), crossings[0], crossings[1]] + .into_iter() + .flatten() + .filter(|u| (0.0..=1.0).contains(u)) + .map(|u| { + let p = a + d * u; + p.x.abs() + p.y.abs() + }) + .fold(f64::INFINITY, f64::min) +} + +/// Halvings at most in [`radial_least`]: enough to bring any finite span below a quarter level. +const RADIAL_HALVINGS: usize = 1100; + +/// The smallest radial level on the segment from `a` to `b` in the unit frame, at most a quarter +/// level under it: the distance to the center for a centered radial. An off-center radial's level +/// `t` is the circle of radius `t` about `f`(1 − `t`), `f` its focal point; the segment's distance +/// from that center less `t` falls as `t` grows (the center moves |`f`| < 1 per level), and the +/// smallest level is where it reaches 0, found by halving. 0 for a focal point not inside the +/// extent ellipse. +fn radial_least(a: Vec2, b: Vec2, focal: Option) -> f64 { + let Some(f) = focal else { return distance_to_segment(a, b, Vec2::ZERO) }; + let reach = f.hypot(); + if reach.is_nan() || reach >= 1.0 { + return 0.0; + } + let past = |t: f64| distance_to_segment(a, b, f * (1.0 - t)) <= t; + // At this level the circle reaches the segment's nearest point to the ellipse's center. + let mut hi = (distance_to_segment(a, b, Vec2::ZERO) / (1.0 - reach)).max(1.0); + let mut lo = 0.0; + if !hi.is_finite() || past(lo) { + return 0.0; + } + for _ in 0..RADIAL_HALVINGS { + let mid = lo + (hi - lo) / 2.0; + if hi - lo <= 0.25 || mid <= lo || mid >= hi { + break; + } + if past(mid) { hi = mid } else { lo = mid } + } + lo +} + +/// How far `c` is from the segment from `a` to `b`. +fn distance_to_segment(a: Vec2, b: Vec2, c: Vec2) -> f64 { + let d = b - a; + let along = (c - a).dot(d) / d.hypot2(); + let u = if along.is_finite() { along.clamp(0.0, 1.0) } else { 0.0 }; + (a + d * u - c).hypot() +} + +/// The stops of one run, from offset 0 to 1: the end colors held out to the ends. +fn whole_run(stops: &[GradientStop]) -> Vec { + let mut out = stops.to_vec(); + if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() { + out.insert(0, GradientStop { offset: 0.0, ..first }); + } + if let Some(last) = out.last_mut() { + // The last stop's midpoint leads nowhere; the copy at 1 takes over. + if last.offset < 1.0 { + let end = GradientStop { offset: 1.0, ..last.clone() }; + out.push(end); + } + } + out +} + +/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two): +/// as it is when it fits, else without the stops that change its colors least, left out one at a +/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still +/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or +/// process plates lie from the line between theirs (a whole unit when its swatch link or color +/// model differs from either of theirs, and a whole unit more when its midpoint or the one before +/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose +/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm. +fn fit_run(one: Vec, budget: usize, method: GradientMethod) -> Vec { + use std::cmp::Reverse; + use std::collections::BinaryHeap; + let budget = budget.max(2); + let n = one.len(); + if n <= budget { + return one; + } + let mix = |x: &Color, y: &Color, v: f32| match method { + GradientMethod::Classic => x.lerp_keep_model(y, v), + m => m.mix(x, y, v), + }; + let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 { + let span = b.offset - a.offset; + let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 }; + // Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`. + let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb()); + let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max); + let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs(); + // Process inks, which colors that look alike can print differently: off their line, or a + // change of color model. + let process = match (&a.color, &s.color, &b.color) { + (Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => { + [(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max) + } + _ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0, + _ => 1.0, + }; + let color = color.max(process); + // Separations: a change of swatch link, or of tint or process plates off their line. + let ink = if s.swatch != a.swatch || s.swatch != b.swatch { + 1.0 + } else { + let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs(); + let plates = match (a.plates, s.plates, b.plates) { + (None, None, None) => 0.0, + (Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max), + _ => 1.0, + }; + tint.max(plates) + }; + // Leaving it out merges two spans, and an off-center midpoint shapes one of them. + let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 }; + // Non-negative, so the bits order as the numbers do (NaN counts as 0). + (color.max(opacity).max(ink) + shaped).max(0.0).to_bits() + }; + // The stops still kept on either side of each stop, and each stop's latest cost entry. + let mut prev: Vec = (0..n).map(|i| i.saturating_sub(1)).collect(); + let mut next: Vec = (1..=n).collect(); + let mut gone = vec![false; n]; + let mut version = vec![0u32; n]; + let mut heap: BinaryHeap> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect(); + let mut left = n; + while left > budget { + let Some(Reverse((_, i, v))) = heap.pop() else { break }; + if gone[i] || v != version[i] { + continue; + } + gone[i] = true; + left -= 1; + let (a, b) = (prev[i], next[i]); + next[a] = b; + prev[b] = a; + for j in [a, b] { + if j != 0 && j != n - 1 { + version[j] += 1; + heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j]))); + } + } + } + one.into_iter() + .enumerate() + .filter(|(i, _)| !gone[*i]) + .map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } }) + .collect() +} + +/// A run mirrored: the stops in reverse, each midpoint measured from the other side. +fn mirror(run: &[GradientStop]) -> Vec { + let mut g = Gradient::new(GradientKind::Linear, run.to_vec()); + g.reverse(); + g.stops +} + +#[cfg(test)] +mod tests; diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs new file mode 100644 index 0000000000..a88319ccaa --- /dev/null +++ b/crates/color/src/gradient/spread/tests.rs @@ -0,0 +1,480 @@ +use kurbo::Vec2; + +use super::*; +use crate::gradient::{GradientPaint, MAX_REPEATS}; +use crate::{Color, GradientMethod}; + +fn spread(kind: GradientKind, spread: GradientSpread, repeats: u32) -> Gradient { + Gradient { spread, repeats, ..Gradient::new(kind, Gradient::default().stops) } +} + +fn black_white() -> Vec { + let mut a = GradientStop::new(0.0, Color::BLACK); + a.midpoint = 0.3; + vec![a, GradientStop::new(1.0, Color::WHITE)] +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +/// Repeat wraps the parameter, `repeats` times along the vector and on past both ends; Reflect +/// runs every other run backwards; Pad leaves it to the stops to clamp; a freeform gradient has +/// no ends to spread past. +#[test] +fn spread_maps_the_parameter_to_a_place_along_the_stops() { + let repeat = spread(GradientKind::Linear, GradientSpread::Repeat, 3); + for (t, want) in [(0.0, 0.0), (0.1, 0.3), (0.5, 0.5), (1.0 / 3.0 + 0.1, 0.3), (1.1, 0.3), (-0.1, 0.7)] { + assert!(close(repeat.spread_param(t), want), "repeat {t}: {}", repeat.spread_param(t)); + } + let reflect = spread(GradientKind::Radial, GradientSpread::Reflect, 3); + for (t, want) in [(0.0, 0.0), (0.1, 0.3), (1.0 / 3.0 + 0.1, 0.7), (2.0 / 3.0 + 0.1, 0.3), (1.0, 1.0), (-0.1, 0.3)] { + assert!(close(reflect.spread_param(t), want), "reflect {t}: {}", reflect.spread_param(t)); + } + let pad = spread(GradientKind::Linear, GradientSpread::Pad, 3); + assert_eq!((pad.spread_param(1.7), pad.spread_param(-0.2)), (1.7, -0.2)); + let free = spread(GradientKind::Freeform, GradientSpread::Repeat, 3); + assert_eq!(free.spread_param(0.7), 0.7); + assert!(!free.is_spread() && !pad.is_spread() && repeat.is_spread()); + assert!(repeat.spread_param(f64::NAN).is_nan()); + // A count out of range (set in code) counts as the nearest one in range. + assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 0).repeat_count(), 1); + assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 5000).repeat_count(), MAX_REPEATS); +} + +/// The native format writes the spread and count only when they are not Pad and 1, reads a file +/// without them as Pad and 1, and reads an unknown spread or a count out of range as the nearest +/// one it can hold. +#[test] +fn the_file_keeps_the_spread_and_reads_bad_values_leniently() { + let plain = serde_json::to_value(Gradient::default()).unwrap(); + assert!(plain.get("spread").is_none() && plain.get("repeats").is_none(), "{plain}"); + let g = spread(GradientKind::Conic, GradientSpread::Reflect, 4); + let v = serde_json::to_value(&g).unwrap(); + assert_eq!((v["spread"].as_str(), v["repeats"].as_u64()), (Some("Reflect"), Some(4))); + assert_eq!(serde_json::from_value::(v).unwrap(), g); + let read = |spread: serde_json::Value, repeats: serde_json::Value| { + let mut v = serde_json::to_value(Gradient::default()).unwrap(); + v["spread"] = spread; + v["repeats"] = repeats; + let g: Gradient = serde_json::from_value(v).unwrap(); + (g.spread, g.repeats) + }; + assert_eq!(read("Repeat".into(), 3.4.into()), (GradientSpread::Repeat, 3)); + assert_eq!(read("Spiral".into(), (-5).into()), (GradientSpread::Pad, 1)); + assert_eq!(read(7.into(), 1e12.into()), (GradientSpread::Pad, MAX_REPEATS)); + assert_eq!(read(serde_json::Value::Null, "many".into()), (GradientSpread::Pad, 1)); + let missing: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); + assert_eq!((missing.spread, missing.repeats), (GradientSpread::Pad, 1)); +} + +/// One run's placement: a level of the period is the gradient's level times the count, for +/// linear, diamond and off-center radial gradients; a conic keeps its placement. +#[test] +fn a_period_holds_one_run_of_the_stops() { + let p = Point::new(72.0, 61.0); + let linear = GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(60.0, 40.0), aspect: 1.0, focal: None }; + let radial = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(60.0, 50.0), aspect: 0.6, focal: Some(Point::new(55.0, 52.0)) }; + let diamond = GradientGeom { end: Point::new(58.0, 56.0), focal: None, ..radial }; + for (kind, geom) in [(GradientKind::Linear, linear), (GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] { + let period = geom.period(kind, 4); + assert!(close(period.param_at(kind, p), 4.0 * geom.param_at(kind, p)), "{kind:?}"); + assert_eq!(period.focal, geom.focal, "{kind:?}"); + } + assert_eq!(radial.period(GradientKind::Conic, 4), radial); + assert_eq!(linear.period(GradientKind::Linear, 0), linear, "a count of 0 counts as 1"); +} + +/// The stops repeated over the area: as many runs as its corners reach, each in place. +#[test] +fn a_linear_gradient_pads_with_its_stops_repeated_over_the_area() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(-5.0, 0.0, 25.0, 10.0)).unwrap(); + assert!(!p.capped); + // Runs of 10 from -10 to 30: four of them. + assert_eq!((p.geom.start, p.geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); + let offsets: Vec = p.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]); + assert!(p.gradient.stops.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); + assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1)); + // Every point gets the color the gradient gives it. + for x in [-4.0, 1.0, 7.5, 13.0, 24.0] { + let q = Point::new(x, 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; + assert!(want.to_rgb().iter().zip(got.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-4), "{x}: {want:?} vs {got:?}"); + } +} + +#[test] +fn reflect_mirrors_every_other_run_and_its_midpoints() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap(); + let colors: Vec = p.gradient.stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]); + // Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7. + assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops); +} + +#[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 }; + let stops = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; + let g = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, stops) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap(); + let offsets: Vec = p.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]); +} + +/// An off-center radial grows about its focal point, a diamond about its center; every point +/// keeps its color. +#[test] +fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { + let c = Point::new(50.0, 50.0); + let radial = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; + let diamond = GradientGeom { focal: None, ..radial }; + for (kind, geom) in [(GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 100.0)).unwrap(); + assert!(p.gradient.stops.len() / 2 >= 10, "{kind:?}: {}", p.gradient.stops.len()); + assert_eq!(p.geom.focal, geom.focal); + for q in [Point::new(72.0, 61.0), Point::new(10.0, 95.0), Point::new(52.0, 49.0)] { + let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } +} + +/// A run of the stops far too short for the area is written with at most so many runs, and pads +/// past them; a gradient of many stops is written with every run the area needs, each run +/// without the stops that change its colors least, within so many stops; a conic, a padding +/// gradient and one without stops have nothing to pad. +#[test] +fn far_repeats_are_capped_and_many_stops_are_sampled() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); + assert!(p.capped); + assert_eq!(p.gradient.stops.len(), 2 * MAX_SPREAD_RUNS); + let many: Vec = (0..=4000).map(|i| GradientStop::new(i as f32 / 4000.0, Color::gray(i as f32 / 4000.0))).collect(); + let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) }; + let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom }; + let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); + assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len()); + for x in [0.7, 33.3, 66.1, 99.5] { + let q = Point::new(x, 0.5); + let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } + assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS); + assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads"); + assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); + let empty = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, vec![]) }; + assert!(empty.padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); +} + +/// The runs written are the ones the area shows: a radial or diamond centered a thousand runs +/// from the area writes the hundred it crosses, not the first 256 from the center; a linear +/// gradient over an area of 1,100 runs far before its start, capped, keeps the runs nearest it. +/// Every point checked gets the color the gradient gives it. +#[test] +fn the_runs_written_cover_a_distant_area() { + let centered = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(1.0, 50.0), aspect: 1.0, focal: None }; + let before = GradientGeom { start: Point::new(5000.0, 0.0), end: Point::new(5001.0, 0.0), aspect: 1.0, focal: None }; + for (kind, geom, area) in [ + (GradientKind::Radial, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)), + (GradientKind::Diamond, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)), + (GradientKind::Linear, before, Rect::new(0.0, 0.0, 1100.0, 100.0)), + ] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, area).unwrap(); + assert_eq!(p.capped, kind == GradientKind::Linear, "{kind:?}"); + assert!(p.gradient.stops.len() <= MAX_SPREAD_STOPS); + for q in [Point::new(1000.5, 0.5), Point::new(1050.25, 50.0), Point::new(1099.5, 99.5), Point::new(1010.75, 90.0)] { + let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } +} + +/// A tiny radial centered just outside the area, beside a side between any evenly spaced +/// samples of it: the runs written start from the area's true innermost level. +#[test] +fn the_innermost_level_is_found_between_samples() { + let c = Point::new(-0.0005, 7.8125); + let geom = GradientGeom { start: c, end: c + Vec2::new(0.001, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 1000.0, 1000.0)).unwrap(); + for q in [Point::new(0.05, 7.8125), Point::new(0.1, 7.9)] { + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Radial, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Radial, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{q:?}: {want:?} vs {got:?}"); + } +} + +/// An area four billion units wide whose long side passes 50 runs from a tiny radial or diamond's +/// center: the runs written start from that side's true innermost level, so the runs beside it +/// keep their colors. +#[test] +fn the_innermost_level_is_found_along_a_very_long_side() { + let area = Rect::new(-1.3e9, 0.0, 2.9e9, 1.0); + for x in [0.3, 12345.678, -7777.25, 98765.4321] { + let c = Point::new(x, -0.00005); + let run = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None }; + let off_center = GradientGeom { focal: Some(c + Vec2::new(0.5e-6, 0.0)), ..run }; + for (kind, geom) in [(GradientKind::Diamond, run), (GradientKind::Radial, run), (GradientKind::Radial, off_center)] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, area).unwrap(); + assert!(p.capped, "{kind:?}"); + for q in [Point::new(x, 1e-5), Point::new(x + 2e-5, 2e-5), Point::new(x - 1e-5, 0.0001)] { + let level = geom.param_at(kind, q); + assert!((40.0..200.0).contains(&level), "{kind:?} {q:?}: level {level}"); + let want = g.sample(g.spread_param(level) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } + } +} + +/// Runs a billion levels from a radial's center are past what the stops' offsets can tell apart: +/// the gradient is written capped, its stops still apart and in order. +#[test] +fn runs_too_far_from_the_center_are_capped() { + let c = Point::new(0.0, 0.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) }; + let p = g.padded_over(&geom, Rect::new(1000.0, 0.0, 1000.0001, 0.0001)).unwrap(); + assert!(p.capped); + assert!(p.gradient.stops.windows(2).all(|w| w[0].offset <= w[1].offset), "in order"); + let distinct = p.gradient.stops.windows(2).filter(|w| w[1].offset > w[0].offset).count(); + assert!(distinct * 2 + 2 >= p.gradient.stops.len(), "the runs stay apart: {distinct} of {}", p.gradient.stops.len()); +} + +/// A run of 1,000 close stops is written only as far out as their offsets stay apart: around +/// level 50,000 it is capped, every stop written apart from the next. +#[test] +fn close_stops_are_written_only_where_they_stay_apart() { + let many: Vec = (0..=1000).map(|i| GradientStop::new(i as f32 / 1000.0, Color::gray(i as f32 / 1000.0))).collect(); + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.001, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: 1, ..Gradient::new(GradientKind::Diamond, many) }; + let p = g.padded_over(&geom, Rect::new(50.0, 0.0, 50.002, 0.002)).unwrap(); + assert!(p.capped); + // Only where one run meets the next do two stops share an offset. + let runs = p.gradient.stops.len() / 1001; + let shared = p.gradient.stops.windows(2).filter(|w| w[1].offset <= w[0].offset).count(); + assert!(shared < runs, "{shared} stops share an offset in {runs} runs"); + // Stops 0.0001234 of a run apart are written out to level 123, on whole runs: an area within + // it gets the gradient's colors, Reflect's mirrored runs included. + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.0001234, Color::gray(0.5)), GradientStop::new(1.0, Color::WHITE)]; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Diamond, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_over(&geom, Rect::new(121.0, 0.0, 123.9, 0.2)).unwrap(); + assert!(p.capped, "past level 123 it pads"); + for x in [121.3, 121.9, 122.5] { + let q = Point::new(x, 0.1); + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Diamond, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Diamond, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } +} + +/// A piece of a gradient that starts where two runs meet (a gradient along a stroke, sliced at a +/// corner there) starts with the new run's first color, not the old run's last. +#[test] +fn a_piece_starting_at_a_sharp_step_takes_the_color_after_it() { + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, Gradient::default().stops) }; + let piece = g.unrolled().span(0.5, 1.0); + assert_eq!(piece.stops.first().map(|s| s.color), Some(Color::WHITE), "{:?}", piece.stops); + assert_eq!(piece.stops.last().map(|s| s.color), Some(Color::BLACK)); + let before = g.unrolled().span(0.0, 0.5); + assert_eq!((before.stops[0].color, before.stops.last().unwrap().color), (Color::WHITE, Color::BLACK)); +} + +/// An area very far past a very short reflecting vector (a file's numbers) is padded without the +/// run count overflowing. +#[test] +fn a_far_area_pads_without_overflowing() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1e-300, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(1e3, 0.0, 2e3, 1.0)); + assert!(p.is_none_or(|p| p.gradient.stops.len() <= MAX_SPREAD_STOPS)); + // Runs of 1e-8: the area's levels lie past 1e19, more than an integer holds. + let geom = GradientGeom { end: Point::new(1e-6, 0.0), ..geom }; + let p = g.padded_over(&geom, Rect::new(1e11, 0.0, 1e11 + 1.0, 1.0)).unwrap(); + assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {} {:?}", p.capped, p.gradient.stops.len(), p.geom); +} + +/// Unrolled, the stops run `repeats` times between 0 and 1 (every other run mirrored with +/// Reflect); one cycle is a run, or a run and its mirror. +#[test] +fn unrolled_and_cycle_stops() { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 3, ..Gradient::new(GradientKind::Linear, black_white()) }; + let u = g.unrolled(); + let offsets: Vec = u.stops.iter().map(|s| (s.offset * 3.0).round()).collect(); + assert_eq!(offsets, [0.0, 1.0, 1.0, 2.0, 2.0, 3.0]); + let colors: Vec = u.stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK, Color::BLACK, Color::WHITE]); + for t in [0.05, 0.4, 0.71, 0.99] { + let (a, b) = (g.sample(g.spread_param(t) as f32).0.to_rgb(), u.sample(t as f32).0.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{t}"); + } + let (cycle, runs) = g.cycle().unwrap(); + assert_eq!((runs, cycle.stops.len()), (2, 4)); + let repeat = Gradient { spread: GradientSpread::Repeat, ..g.clone() }; + assert_eq!(repeat.cycle().unwrap().1, 1); + assert!(Gradient::default().cycle().is_none()); + assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); +} + +/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run +/// when unrolled, each run without the stops that change its colors least: its last run ends on +/// its last color instead of the rest padding with it. +#[test] +fn unrolled_many_stops_keep_every_run() { + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) }; + let u = g.unrolled(); + assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len()); + for t in [0.005, 0.5051, 0.9149, 0.9951] { + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = u.sample(t as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}"); + } + // A padding gradient fitted to fewer stops keeps its ends. + let pad = Gradient::new(GradientKind::Linear, g.stops.clone()); + let fitted = pad.fitted(16); + assert_eq!(fitted.stops.len(), 16); + for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] { + assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color); + } + assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_))); +} + +/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1) +/// is written within the limit, every run the area needs included. +#[test] +fn a_run_over_the_stop_limit_is_written_within_it() { + let stops: Vec = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap(); + assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len()); + assert!(g.unrolled_within(256).stops.len() <= 256); +} + +/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a +/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose +/// them. +#[test] +fn fitting_keeps_sharp_steps_and_swatch_links() { + let mut stops: Vec = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect(); + let red = Color::rgb(1.0, 0.0, 0.0); + // A band of red from 0.5003 to 0.5013, with a sharp step at each side. + for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] { + let gray = Color::gray(offset); + stops.push(GradientStop::new(offset, color.unwrap_or(gray))); + } + let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) }; + stops.push(spot.clone()); + // Three stops of one ink that look alike but print at different tints. + for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] { + stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) }); + } + // A span shaped by an off-center midpoint. + if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) { + s.midpoint = 0.13; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let g = Gradient::new(GradientKind::Linear, stops); + let fitted = g.fitted(32); + assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len()); + let red_at = |t: f32| fitted.sample(t).0.to_rgb(); + assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays"); + assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow"); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays"); + assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays"); + // Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the + // first stays. (The stop before an ink starts stays as well: leaving it out would blend the + // ink in sooner.) + let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) }; + let few = vec![ + GradientStop::new(0.0, Color::gray(0.0)), + GradientStop::new(0.2, Color::gray(0.17)), + GradientStop::new(0.3, Color::gray(0.3)), + ink(0.4, 0.2), + ink(0.5, 0.9), + ink(0.6, 0.2), + GradientStop::new(1.0, Color::gray(1.0)), + ]; + let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]); + // A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line + // gives. + let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5)); + let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5); + let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)]; + let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) }; + let fitted = perceptual.fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + // A black of other inks between blacks that look the same prints differently: it stays. + let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 }); + let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 }); + let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)]; + let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() }; + let u = spread.unrolled_within(128); + assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run"); +} + +/// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and +/// PDF write as images) repeat with the stops, for each kind. +#[test] +fn pixels_repeat_and_reflect_for_every_kind() { + let b = Rect::new(0.0, 0.0, 40.0, 40.0); + let c = Point::new(20.0, 20.0); + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + for sp in [GradientSpread::Repeat, GradientSpread::Reflect] { + for method in [GradientMethod::Classic, GradientMethod::Perceptual] { + let mut g = GradientPaint::new(Gradient { spread: sp, repeats: 3, method, ..Gradient::new(kind, Gradient::default().stops) }); + let geom = if kind == GradientKind::Linear { + GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(40.0, 10.0), aspect: 1.0, focal: None } + } else { + GradientGeom { start: c, end: c + Vec2::new(18.0, 4.0), aspect: 0.8, focal: None } + }; + g.geom = Some(geom); + let px: Vec<[u8; 4]> = g.pixels(b, b, 40, 40, false, &Color::to_rgb).collect(); + for (i, got) in px.iter().enumerate().step_by(37) { + let p = Point::new((i % 40) as f64 + 0.5, (i / 40) as f64 + 0.5); + let t = g.gradient.spread_param(geom.param_at(kind, p)); + // Not where Repeat jumps from the last stop back to the first. + if sp == GradientSpread::Repeat && !(0.03..0.97).contains(&t) { + continue; + } + let want = g.gradient.sample(t as f32).0.to_rgba8(1.0); + assert!(got[0].abs_diff(want[0]) <= 2, "{kind:?} {sp:?} {method:?} {p:?}: {got:?} vs {want:?}"); + } + } + } + } +} + +/// A conic gradient's wedges: as many for each time the stops run around as for one turn, at most +/// so many. +#[test] +fn conic_wedges_count_each_run() { + let g = spread(GradientKind::Conic, GradientSpread::Repeat, 4); + assert_eq!(g.conic_wedge_count(360), 1440); + assert_eq!(spread(GradientKind::Conic, GradientSpread::Pad, 4).conic_wedge_count(360), 360); + assert_eq!(spread(GradientKind::Conic, GradientSpread::Reflect, MAX_REPEATS).conic_wedge_count(360), crate::gradient::MAX_CONIC_WEDGES); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 03e7dc10e7..0d84e9e6b3 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -16,7 +16,7 @@ pub mod swatch; pub use blend::BlendMode; pub use freeform::{Freeform, FreeformMode, FreeformPoint}; -pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop}; +pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop}; pub use libraries::SwatchLibrary; pub use swatch::{Swatch, SwatchGroup, default_swatches}; diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 800785f6f7..996c963c11 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -196,8 +196,10 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { let at = if mirrored { 1.0 - s.offset } else { s.offset }; format!("{} {}%", css_color(&s.color, s.opacity), at * 100.0) }; + // Stops that repeat or reflect run that many times across the box. + let unrolled = g.gradient.unrolled(); let stops: Vec = - if mirrored { g.gradient.stops.iter().rev().map(stop).collect() } else { g.gradient.stops.iter().map(stop).collect() }; + if mirrored { unrolled.stops.iter().rev().map(stop).collect() } else { unrolled.stops.iter().map(stop).collect() }; match g.gradient.kind { GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")), // CSS sweeps clockwise from straight up; the angle is counted counterclockwise diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs index eaee50dd59..f3de69baf9 100644 --- a/crates/color/src/palette_io/tests.rs +++ b/crates/color/src/palette_io/tests.rs @@ -261,6 +261,29 @@ fn css_preserves_fractional_gradient_stop_positions() { } } +/// A gradient swatch that repeats or reflects has its stops written that many times across the +/// box, every other run backwards for Reflect. +#[test] +fn css_writes_spread_gradient_swatches_with_their_stops_repeated() { + for (spread, want) in [ + (crate::GradientSpread::Repeat, "#ffffff 0%, #000000 50%, #ffffff 50%, #000000 100%"), + (crate::GradientSpread::Reflect, "#ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%"), + ] { + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { + name: "Bands".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { spread, repeats: 2, ..Gradient::default() }))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains(&format!("--bands: linear-gradient(90deg, {want});")), "{spread:?}: {text}"); + } +} + #[test] fn holds_counts_the_swatches_each_format_writes() { // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index b1aa9727ba..ba0f5fc013 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -10,7 +10,7 @@ mod stroke_style; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; -pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +pub use stroke_style::{ArrowAlign, Arrowhead, CustomArrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. // A stroke takes 328 bytes and a fill 104; an appearance holds a few of each. diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 923275999b..f8a58758d8 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -193,8 +193,10 @@ impl StrokeLayer { /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. pub fn head_reach(&self, end: bool) -> f64 { - let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0) + let head = if end { &self.end_arrow } else { &self.start_arrow }; + // A custom head can paint in front of its tip, which Extend puts past the end point. + let reach = |k: &Arrowhead| if self.arrow_align == ArrowAlign::Extend { k.reach().max(k.length()) } else { k.reach() }; + head.as_ref().map_or(0.0, |k| reach(k) * self.arrow_weight(end) + self.width / 2.0) } /// How far the arrowheads can reach from the path's end points (0 without heads). pub fn arrow_reach(&self) -> f64 { diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 407780177e..3d89615016 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -202,6 +202,13 @@ impl AngleProfile { if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 } } + /// The angle at `t` (0..1) as [`Self::at`] reads it, but in the samples' own running degrees, + /// so the whole turns the curve makes along the stroke are kept; 0 without any samples. + pub fn unwrapped_at(&self, t: f64) -> f64 { + let v = interpolated(&self.points, t).unwrap_or(0.0); + if v.is_finite() { v } else { 0.0 } + } + /// A profile from (position, degrees) samples in order along the stroke: non-finite ones are /// dropped, each sample is joined to the one before it the shorter way round, and the curve is /// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample. diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 46a0c30c33..18b226c676 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use vectorcraft_geom::{PathData, Rect}; + use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -53,8 +57,74 @@ impl Dash { /// (the open arrow's arms are cut square a little behind the box). const HEAD_REACH: f64 = 4.6; +/// An arrowhead the user drew (Stroke panel menu › Edit Arrowheads…), carried with its art so a +/// document that uses it draws it wherever it opens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "CustomArrowheadData")] +pub struct CustomArrowhead { + /// The name the arrowhead menus show (the symbol's name in the arrowheads file). + pub name: String, + /// The filled outline (non-zero rule) in head units: x from the tip (0) back along the line, y + /// across it, positive to the right of the direction the head points. A head is 4 units wide, + /// as the built-in heads are, and a unit is the head's weight. + pub path: PathData, +} + +#[derive(Deserialize)] +struct CustomArrowheadData { + name: String, + #[serde(default)] + path: PathData, +} + +impl From for CustomArrowhead { + fn from(d: CustomArrowheadData) -> Self { + CustomArrowhead::new(d.name, d.path) + } +} + +impl CustomArrowhead { + /// The most anchors a custom head's outline may have, and the most subpaths. + pub const MAX_ANCHORS: usize = 4096; + /// How far from the tip a custom head's outline may reach, in head units. + pub const MAX_UNITS: f64 = 64.0; + + /// Head `name` with outline `path` (in head units). An outline with more than + /// [`Self::MAX_ANCHORS`] anchors or subpaths, a point that is not finite, or a point farther + /// than [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. + pub fn new(name: String, path: PathData) -> Self { + let path = if Self::fits(&path) { path } else { PathData::default() }; + CustomArrowhead { name, path } + } + /// Whether outline `path` has at most [`Self::MAX_ANCHORS`] anchors and as many subpaths, with + /// finite points within [`Self::MAX_UNITS`] only. + pub fn fits(path: &PathData) -> bool { + let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::(); + let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS; + anchors <= Self::MAX_ANCHORS + && path.subpaths.len() <= Self::MAX_ANCHORS + && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) + } + fn bounds(&self) -> Option { + self.path.control_bounds() + } + /// How far any part of the head can be from its tip, in head units. + pub fn reach(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x0.hypot(b.y0).max(b.x1.hypot(b.y0)).max(b.x0.hypot(b.y1)).max(b.x1.hypot(b.y1))) + } + /// How far the head spreads either side of its line, in head units. + pub fn half_width(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.y0.abs().max(b.y1.abs())) + } + /// Where the stroke ends under the head, in head units back from the tip: halfway along the + /// head's length. + pub fn stop(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x1.max(0.0) / 2.0) + } +} + /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Arrowhead { Triangle, TriangleOpen, @@ -102,6 +172,8 @@ pub enum Arrowhead { /// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as /// the other heads. LineArrow, + /// A head the user drew, with its art. + Custom(Arc), } impl Arrowhead { @@ -151,23 +223,43 @@ impl Arrowhead { ]; /// How far any part of the head can be from its tip, in units of its weight. - pub fn reach(self) -> f64 { + pub fn reach(&self) -> f64 { match self { // Its farthest corners are 4 + ½√2 back and 4 − ½√2 out. Arrowhead::LineArrow => 5.75, + Arrowhead::Custom(c) => c.reach(), _ => HEAD_REACH, } } /// How far the head spreads either side of its line, in units of its weight. - pub fn half_width(self) -> f64 { + pub fn half_width(&self) -> f64 { match self { Arrowhead::LineArrow => 4.0, + Arrowhead::Custom(c) => c.half_width(), _ => 2.0, } } + /// How far past its end point a head placed with [`ArrowAlign::Extend`] can paint, in units of + /// its weight: the built-in heads' length, or a custom head's reach past its tip, which sits + /// [`CustomArrowhead::stop`] past the end point. + pub fn length(&self) -> f64 { + match self { + Arrowhead::Custom(c) => c.stop() + c.reach(), + _ => 4.0, + } + } - /// The name the arrowhead menus show. - pub fn label(self) -> &'static str { + /// The name the arrowhead menus show: [`Self::label`], or a custom head's own name. + pub fn name(&self) -> &str { + match self { + Arrowhead::Custom(c) => &c.name, + _ => self.label(), + } + } + + /// The name the arrowhead menus show for a built-in head ("Custom" for a custom one, whose + /// own name is [`Self::name`]). + pub fn label(&self) -> &'static str { match self { Arrowhead::Triangle => "Triangle", Arrowhead::TriangleOpen => "Triangle (open)", @@ -210,6 +302,7 @@ impl Arrowhead { Arrowhead::TriangleBar => "Triangle to Bar", Arrowhead::Oval => "Oval", Arrowhead::LineArrow => "Line Arrow", + Arrowhead::Custom(_) => "Custom", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index aa2ba0100c..22e5659051 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -337,13 +337,65 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() { ]; for name in SAVED { let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); - assert_eq!(serde_json::to_value(a).unwrap(), name); + assert_eq!(serde_json::to_value(&a).unwrap(), name); assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); } let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); } +/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line. +fn custom_head() -> Arrowhead { + use vectorcraft_geom::{PathData, Point, SubPath}; + let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path))) +} + +#[test] +fn custom_arrowheads_carry_their_art_and_measure_it() { + let head = custom_head(); + assert_eq!((head.name(), head.label()), ("Wedge", "Custom")); + assert_eq!(head.reach(), 3.0f64.hypot(2.0)); + assert_eq!(head.half_width(), 2.0); + let Arrowhead::Custom(c) = &head else { panic!("custom") }; + assert_eq!(c.stop(), 1.5, "the line stops halfway along the head"); + // Saved with the stroke, art and all, beside the built-in heads' names. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone())); + let json = serde_json::to_value(&st).unwrap(); + assert_eq!(json["start_arrow"], "Triangle"); + assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge"); + let back: StrokeLayer = serde_json::from_value(json).unwrap(); + assert_eq!(back, st); + // Extended, the tip sits the stop past the end point; on it, only the reach counts. + assert_eq!(head.length(), 1.5 + head.reach()); + assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0); + st.arrow_align = ArrowAlign::Tip; + assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0); +} + +#[test] +fn custom_arrowheads_out_of_bounds_draw_nothing() { + let Arrowhead::Custom(c) = custom_head() else { panic!("custom") }; + let mut path = c.path.clone(); + path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0)); + // A file can hold what `new` drops: reading it drops it too. + let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap(); + let far: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0)); + assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0)); + let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1]; + let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true))); + assert!(many.path.subpaths.is_empty(), "too many anchors"); + // Empty subpaths add no anchors, and still count. + let mut hollow = c.path.clone(); + hollow.subpaths.extend(std::iter::repeat_n(vectorcraft_geom::SubPath::new(vec![], false), CustomArrowhead::MAX_ANCHORS)); + assert!(!CustomArrowhead::fits(&hollow), "too many subpaths"); + let json = serde_json::to_value(CustomArrowhead { name: "Hollow".into(), path: hollow }).unwrap(); + let hollow: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert!(hollow.path.subpaths.is_empty(), "too many subpaths, read back"); +} + #[test] fn arrow_align_defaults_to_extend_and_round_trips() { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 4e51777060..d9e179286f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -5,7 +5,8 @@ //! winding and to the start points that keep them from twisting, or to the anchors the Blend //! tool clicked. //! - **Paint**: colours, gradients (stops resampled to a common set when their counts differ), -//! opacity, stroke weight, dashes, miter limit and width profiles interpolate; what can't +//! opacity, stroke weight, dashes, miter limit, width profiles and the pen pressure, tilt, +//! bearing and rotation that brushes read interpolate; what can't //! (caps, joins, brushes, fonts, symbols, text content) switches halfway. //! - **Groups** pair their children in stacking order; the children one group has more of grow //! out of the other group's centre. Compound paths stay compound paths. @@ -22,7 +23,7 @@ use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; -use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; +use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeLayer, WidthProfile}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; use crate::node::{Node, NodeId, NodeKind}; use crate::text::TextObject; @@ -152,6 +153,11 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option usize { a.checked_div(x).map_or(0, |q| q * b) } -/// Interpolate width profiles (none is uniform) at every position either has a width point. +/// Interpolate width profiles (none is uniform) at every position either has a width point, +/// keeping their discontinuous points (a position where either steps gives two points). fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option { if x.is_none() && y.is_none() { return None; @@ -239,20 +246,135 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform)); let mut at: Vec = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - let points = at - .into_iter() - .map(|p| { - let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p)); - (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)) - }) - .collect(); + // Within the distance `around` reads a discontinuous point at, so a point near one keeps it. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-7); + // Each profile is a smooth curve through its own points: samples between the positions keep + // both curves' shapes where the other profile's points and steps change the tangents. Two + // profiles with points at the same positions need none (so a preset on both keys stays it). + let same_positions = x.points.len() == y.points.len() && x.points.iter().zip(&y.points).all(|(p, q)| (p.0 - q.0).abs() <= 1e-7); + let per_span = if same_positions { 1 } else { (4096 / at.len().max(1)).clamp(1, 8) }; + let mut points = Vec::with_capacity(at.len() * per_span + 2); + let mix = |p: f64, (l1, r1): (f64, f64), (l2, r2): (f64, f64)| (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)); + for (k, &p) in at.iter().enumerate() { + let ((xb, xa), (yb, ya)) = (x.around(p), y.around(p)); + points.push(mix(p, xb, yb)); + // A discontinuous point ends the curve on each side even where both widths match. + if steps_at(x, p) || steps_at(y, p) { + points.push(mix(p, xa, ya)); + } + if let Some(&q) = at.get(k + 1) { + for j in 1..per_span { + let f = p + (q - p) * j as f64 / per_span as f64; + points.push(mix(f, x.at(f), y.at(f))); + } + } + } Some(WidthProfile { points }) } -/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale and width -/// profile into `o` (a copy of the nearer stroke, so caps, joins, alignment, arrowheads and -/// brushes switch halfway). +/// Whether width profile `w` has a discontinuous point at `p` (two points there, within `1e-7`, +/// as [`WidthProfile::around`] reads them). +fn steps_at(w: &WidthProfile, p: f64) -> bool { + let i = w.points.partition_point(|q| q.0 < p - 1e-7); + w.points.get(i..).unwrap_or_default().iter().take_while(|q| q.0 <= p + 1e-7).count() > 1 +} + +/// The positions along the stroke where either of two pen recordings has a sample, in order. +fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec { + let mut at: Vec = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect(); + at.sort_by(f64::total_cmp); + // Within the distance `step_after` reads a step at, so a sample near a step keeps the step. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); + if at.is_empty() { + at = vec![0.0, 1.0]; + } + at +} + +/// The value pen recording `p` steps to at position `f`, where two or more of its samples share +/// that position (reading at `f` gives the first of them); None where it doesn't step. +fn step_after(p: Option<&[(f64, f64)]>, f: f64) -> Option { + let p = p?; + let i = p.partition_point(|q| q.0 < f - 1e-9); + let j = p.partition_point(|q| q.0 <= f + 1e-9).checked_sub(1)?; + (j > i && (p.get(i)?.0 - f).abs() <= 1e-9).then(|| p.get(j).map(|q| q.1)).flatten() +} + +/// Two pen recordings read at every position either has a sample, as (position, x's value, y's +/// value): `read_x` and `read_y` read them, and `clean` makes a stepped sample's value one they +/// would read. A position where either steps gives two, before and after the step. +fn paired_samples( + x: Option<&[(f64, f64)]>, + y: Option<&[(f64, f64)]>, + read_x: impl Fn(f64) -> f64, + read_y: impl Fn(f64) -> f64, + clean: impl Fn(f64) -> f64, +) -> Vec<(f64, f64, f64)> { + let mut out = Vec::new(); + for f in sample_positions(x, y) { + let (a, b) = (read_x(f), read_y(f)); + out.push((f, a, b)); + let (sa, sb) = (step_after(x, f), step_after(y, f)); + if sa.is_some() || sb.is_some() { + out.push((f, sa.map_or(a, &clean), sb.map_or(b, &clean))); + } + } + out +} + +/// Interpolate pen pressure or tilt recordings at every position either has a sample, keeping +/// their steps. A stroke without one reads as [`PressureProfile::MID`], as brushes read it; None +/// when neither has one. +fn lerp_pressure(x: Option<&PressureProfile>, y: Option<&PressureProfile>, t: f64) -> Option { + if x.is_none() && y.is_none() { + return None; + } + let read = |p: Option<&PressureProfile>, f: f64| p.map_or(PressureProfile::MID, |p| p.at(f)); + let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { PressureProfile::MID }; + let samples = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean); + PressureProfile::from_samples(samples.into_iter().map(|(f, a, b)| (f, lerp_f64(a, b, t)))) +} + +/// Interpolate pen angle recordings (bearing or barrel rotation) at every position either has a +/// sample: the steps turn from one key's angle to the other's the shorter way round at the +/// stroke's start, and keep the whole turns each recording makes along the stroke. A stroke +/// without one reads as 0°, as brushes read it; None when neither has one. +fn lerp_angle(x: Option<&AngleProfile>, y: Option<&AngleProfile>, t: f64) -> Option { + if x.is_none() && y.is_none() { + return None; + } + let read = |p: Option<&AngleProfile>, f: f64| p.map_or(0.0, |p| p.unwrapped_at(f)); + let clean = |v: f64| if v.is_finite() { v } else { 0.0 }; + let pairs = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean); + // Whole turns that bring the two angles within half a turn of each other at the first position. + let d0 = pairs.first().map_or(0.0, |&(_, a, b)| b - a); + let turns = vectorcraft_geom::normalize_deg(d0) - d0; + let value = |a: f64, b: f64| a + (b - a + turns) * t; + // Both curves are straight between two positions; samples are added where the steps turn + // through 90° or more between them, since `AngleProfile::from_samples` joins neighbours the + // shorter way round. A document's recordings can be long, so the added samples are limited. + let mut budget = 4 * PressureProfile::MAX_POINTS; + let mut samples = Vec::with_capacity(pairs.len()); + let mut prev: Option<(f64, f64)> = None; + for (f, a, b) in pairs { + let v = value(a, b); + if let Some((pf, pv)) = prev.filter(|p| f - p.0 > 1e-9) { + let parts = ((v - pv).abs() / 90.0).ceil().min((budget + 1) as f64) as usize; + for k in 1..parts { + let g = pf + (f - pf) * k as f64 / parts as f64; + samples.push((g, value(read(x, g), read(y, g)))); + } + budget -= parts.saturating_sub(1).min(budget); + } + samples.push((f, v)); + prev = Some((f, v)); + } + AngleProfile::from_samples(samples) +} + +/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale, width +/// profile and the pen pressure, tilt, bearing and rotation its brush reads into `o` (a copy of +/// the nearer stroke, so caps, joins, alignment, arrowheads and brushes switch halfway). fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) { let tf = t as f32; o.paint = lerp_paint(&x.paint, &y.paint, tf); @@ -262,6 +384,10 @@ fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) { o.dash = lerp_dash(x.dash.as_ref(), y.dash.as_ref(), t); o.arrow_scale = (lerp_f64(x.arrow_scale.0, y.arrow_scale.0, t), lerp_f64(x.arrow_scale.1, y.arrow_scale.1, t)); o.profile = lerp_profile(x.profile.as_ref(), y.profile.as_ref(), t); + o.pressure = lerp_pressure(x.pressure.as_ref(), y.pressure.as_ref(), t); + o.tilt = lerp_pressure(x.tilt.as_ref(), y.tilt.as_ref(), t); + o.bearing = lerp_angle(x.bearing.as_ref(), y.bearing.as_ref(), t); + o.rotation = lerp_angle(x.rotation.as_ref(), y.rotation.as_ref(), t); } /// Interpolate appearance stacks: item by item when their structure matches, else the top fill @@ -385,6 +511,161 @@ fn reverse_closed(sp: &mut SubPath) { sp.anchors.rotate_right(1); } +/// How a key's subpaths were turned for its blend, so that its stroke's profiles, pen +/// recordings and arrowheads turn with it: position `u` along the turned subpath is position +/// `start + u` (`start - u` when `reversed`) along the key's own, wrapping round a closed one. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Orient { + start: f64, + reversed: bool, + closed: bool, +} + +impl Orient { + fn of(sp: &SubPath) -> Self { + Self { closed: sp.closed, ..Self::default() } + } + + /// The subpath now starts at the fraction `f` of its length. + fn rotate(&mut self, f: f64) { + self.start += if self.reversed { -f } else { f }; + } + + /// The subpath now runs the other way. + fn reverse(&mut self) { + self.start += if self.reversed { -1.0 } else { 1.0 }; + self.reversed = !self.reversed; + } + + /// As [`start_at`] turns `sp` (call it before). + fn start_at(&mut self, sp: &SubPath, i: usize) { + let n = sp.anchors.len(); + if i >= n { + return; + } + if sp.closed { + self.rotate(arc_fraction(sp, i)); + } else if i + 1 == n && n > 1 { + self.reverse(); + } + } + + /// Where a closed subpath now starts, as a fraction of its length; 0 for an open one. + fn seam(&self) -> f64 { + let s = if self.closed { self.start.rem_euclid(1.0) } else { 0.0 }; + if s > 1e-9 && s < 1.0 - 1e-9 { s } else { 0.0 } + } + + fn is_identity(&self) -> bool { + !self.reversed && self.seam() == 0.0 + } + + /// Whether two subpaths were turned alike, so one stroke along both turns the same way. + fn same_turn(&self, o: &Self) -> bool { + self.reversed == o.reversed && (self.seam() - o.seam()).abs() < 1e-9 + } +} + +/// How far along `sp` its anchor `i` sits, as a fraction of its length. +fn arc_fraction(sp: &SubPath, i: usize) -> f64 { + let pos = sp.anchor_arc_positions(); + match (pos.get(i), pos.last()) { + (Some(&at), Some(&total)) if total.is_finite() && total > 1e-12 => (at / total).clamp(0.0, 1.0), + _ => 0.0, + } +} + +/// Samples (position, value) along a key's subpath, read along the subpath turned as `o` says. +/// A closed subpath starting elsewhere starts the samples there too: the ones before it move +/// past the seam (`wrap` applied), which keeps any step the seam makes, and a value between +/// samples is read with `at`. A reversed subpath then reads them backwards (`flip` applied). +fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wrap: impl Fn(V) -> V, flip: impl Fn(V) -> V) -> Vec<(f64, V)> { + let s = o.seam(); + let mut out = points.to_vec(); + if s > 0.0 + && let (Some(&(_, first)), Some(&(_, last))) = (points.first(), points.last()) + { + let i = points.partition_point(|q| q.0 < s - 1e-9); + let j = points.partition_point(|q| q.0 <= s + 1e-9).max(i); + let (before, after) = match points.get(i..j) { + Some([p, .., q]) => (p.1, q.1), + Some([p]) => (p.1, p.1), + _ => (at(s), at(s)), + }; + out = Vec::with_capacity(points.len() + 4); + out.push((0.0, after)); + out.extend(points.get(j..).unwrap_or_default().iter().map(|&(t, v)| (t - s, v))); + out.push((1.0 - s, last)); + out.push((1.0 - s, wrap(first))); + out.extend(points.get(..i).unwrap_or_default().iter().map(|&(t, v)| (t + 1.0 - s, wrap(v)))); + out.push((1.0, wrap(before))); + } + if o.reversed { + out = out.into_iter().rev().map(|(t, v)| (1.0 - t, flip(v))).collect(); + } + out +} + +/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap +/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its +/// arrowheads. +fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { + if o.is_identity() { + return; + } + let same = |v: f64| v; + if let Some(p) = &mut s.profile { + let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect(); + // A width profile is a smooth curve through its points: where the new start splits a span, + // samples of that span keep its curve once its ends become ends of the profile. + let seam = o.seam(); + let i = p.points.partition_point(|q| q.0 < seam); + if seam > 0.0 + && let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.points.get(j)), p.points.get(i)) + && seam - a.0 > 1e-9 + && b.0 - seam > 1e-9 + { + const SAMPLES: usize = 32; + let (a, b) = (a.0, b.0); + let extra: Vec<(f64, (f64, f64))> = + (1..SAMPLES).map(|k| a + (b - a) * k as f64 / SAMPLES as f64).filter(|f| (f - seam).abs() > 1e-9).map(|f| (f, p.at(f))).collect(); + pts.splice(i..i, extra); + } + let out = reoriented(&pts, o, |f| p.at(f), |v| v, |(l, r)| (r, l)); + p.points = out.into_iter().map(|(t, (l, r))| (t, l, r)).collect(); + } + for p in [&mut s.pressure, &mut s.tilt].into_iter().flatten() { + let out = reoriented(&p.points, o, |f| p.at(f), same, same); + p.points = out; + } + for p in [&mut s.bearing, &mut s.rotation].into_iter().flatten() { + // The samples moved past the seam carry the whole turns the recording makes along the + // stroke, so the angle does not jump by them there. + let turns = match (p.points.first(), p.points.last()) { + (Some(a), Some(b)) if (b.1 - a.1).is_finite() => ((b.1 - a.1) / 360.0).round() * 360.0, + _ => 0.0, + }; + let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); + p.points = out; + } + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } +} + +/// `n` with every stroke read along its subpaths turned as `o` says. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + } + } +} + /// Anchor positions of a subpath relative to its box, scaled to unit size. fn normalised(sp: &SubPath) -> Vec { let pts = sp.anchors.iter().map(|a| a.p); @@ -422,6 +703,8 @@ fn best_rotation(a: &SubPath, b: &SubPath) -> usize { pub struct PathPair { a: Vec, b: Vec, + /// How each path's subpaths were turned, where all of them turned alike (else unturned). + orient: (Orient, Orient), } impl PathPair { @@ -433,6 +716,7 @@ impl PathPair { let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n)); + let mut orient = (Orient::default(), Orient::default()); for i in 0..n { let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { (Some(x), Some(y)) => (x.clone(), y.clone()), @@ -440,17 +724,21 @@ impl PathPair { (None, Some(y)) => (degenerate(y, ca), y.clone()), (None, None) => continue, }; + let (mut oa, mut ob) = (Orient::of(&sa), Orient::of(&sb)); let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some()); if explicit { if let Some(s) = starts.0 { + oa.start_at(&sa, s); start_at(&mut sa, s); } if let Some(s) = starts.1 { + ob.start_at(&sb, s); start_at(&mut sb, s); } } let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2; if closed && (sa.area() * sb.area()) < 0.0 { + ob.reverse(); reverse_closed(&mut sb); } let m = sa.anchors.len().max(sb.anchors.len()); @@ -458,12 +746,26 @@ impl PathPair { grow(&mut sb, m); if closed && !explicit { let r = best_rotation(&sa, &sb); + if r != 0 { + ob.rotate(arc_fraction(&sb, r)); + } sb.anchors.rotate_left(r); } + // One stroke runs along every subpath of a key, each reading it by its own length, so + // it turns only when all of the key's subpaths turned alike. + if i == 0 { + orient = (oa, ob); + } + if i < a.subpaths.len() && !orient.0.same_turn(&oa) { + orient.0 = Orient::default(); + } + if i < b.subpaths.len() && !orient.1.same_turn(&ob) { + orient.1 = Orient::default(); + } va.push(sa); vb.push(sb); } - Self { a: va, b: vb } + Self { a: va, b: vb, orient } } /// The path at `t` (0 = `a`, 1 = `b`). @@ -578,7 +880,12 @@ impl Lerp { _ => Shape::Other, }, }; - Self { a: a.clone(), b: b.clone(), shape } + let (mut a, mut b) = (a.clone(), b.clone()); + if let Shape::Path { pair, .. } = &shape { + reorient_strokes(&mut a, pair.orient.0); + reorient_strokes(&mut b, pair.orient.1); + } + Self { a, b, shape } } /// The object at `t` (0 = `a`, 1 = `b`), with id 0. @@ -1480,7 +1787,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; // Easing needs a spine: a blend made without one keeps its steps evenly spaced. - let eased = rail.is_some() && !spec.no_spine; + let eased = rail.is_some() && spec.eases_along_spine(); let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { @@ -1495,6 +1802,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::appearance::Arrowhead; use vectorcraft_geom::{Rect, shapes}; #[test] @@ -1656,6 +1964,25 @@ mod tests { assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up"); } + /// A blend between two gradients that repeat steps the count; one between a gradient that + /// pads and one that repeats switches halfway, as the method does. + #[test] + fn blend_between_repeating_gradients_steps_the_count() { + use vectorcraft_color::GradientSpread; + let spread = |spread, repeats| Paint::Gradient(Box::new(GradientPaint::new(Gradient { spread, repeats, ..Gradient::default() }))); + let at = |a: &Paint, b: &Paint, t: f32| match lerp_paint(a, b, t) { + Paint::Gradient(g) => (g.gradient.spread, g.gradient.repeat_count()), + p => panic!("a gradient, not {p:?}"), + }; + let (two, twenty) = (spread(GradientSpread::Repeat, 2), spread(GradientSpread::Repeat, 20)); + assert_eq!(at(&two, &twenty, 0.25), (GradientSpread::Repeat, 7)); + assert_eq!(at(&two, &twenty, 0.5), (GradientSpread::Repeat, 11)); + assert_eq!(at(&two, &twenty, 1.0), (GradientSpread::Repeat, 20)); + let pad = spread(GradientSpread::Pad, 1); + assert_eq!(at(&pad, &twenty, 0.25), (GradientSpread::Pad, 1)); + assert_eq!(at(&pad, &twenty, 0.75), (GradientSpread::Repeat, 20)); + } + #[test] fn blend_between_tints_of_one_swatch_keeps_the_link() { let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint }; @@ -1731,6 +2058,146 @@ mod tests { assert_eq!(p.at(0.5), (1.0, 1.0)); } + #[test] + fn pen_pressure_tilt_and_angles_interpolate_for_brushes() { + let stroked = |pressure: Option, tilt: Option, bearing: Option| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + s.brush = Some("Pressure Round".into()); + s.pressure = pressure.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] }); + s.tilt = tilt.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] }); + s.bearing = bearing.map(|a| AngleProfile { points: vec![(0.0, a), (1.0, a)] }); + } + ap + }; + let at = |ap: &Appearance, f: f64| { + let s = ap.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let read = |p: Option<&PressureProfile>| p.map_or(PressureProfile::MID, |p| p.at(f)); + (read(s.pressure.as_ref()), read(s.tilt.as_ref()), s.bearing.as_ref().map_or(0.0, |p| p.at(f))) + }; + let (light, heavy) = (stroked(Some(0.0), Some(0.2), Some(170.0)), stroked(Some(1.0), Some(0.6), Some(-170.0))); + let (p, tilt, bearing) = at(&lerp_appearance(&light, &heavy, 0.25), 0.5); + assert!((p - 0.25).abs() < 1e-9 && (tilt - 0.3).abs() < 1e-9, "a quarter of the way: {p} {tilt}"); + assert!((bearing - 175.0).abs() < 1e-9, "the angle turns the shorter way round, through 180°: {bearing}"); + assert!((at(&lerp_appearance(&light, &heavy, 0.5), 0.5).2.abs() - 180.0).abs() < 1e-9); + // A stroke drawn without a recording reads as half pressure, half tilt and 0°. + let plain = stroked(None, None, None); + let (p, tilt, bearing) = at(&lerp_appearance(&heavy, &plain, 0.5), 0.0); + assert!((p - 0.75).abs() < 1e-9 && (tilt - 0.55).abs() < 1e-9 && (bearing + 85.0).abs() < 1e-9, "{p} {tilt} {bearing}"); + // Profiles that vary along the stroke interpolate at each position. + let mut ramp = stroked(None, None, None); + if let Some(s) = ramp.stroke_mut() { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + } + let mid = lerp_appearance(&ramp, &heavy, 0.5); + assert!((at(&mid, 0.0).0 - 0.5).abs() < 1e-9 && (at(&mid, 0.5).0 - 0.75).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure)); + // A step in a recording (two samples at one position) stays a step. + let mut stepped = stroked(None, None, None); + if let Some(s) = stepped.stroke_mut() { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }); + } + let mid = lerp_appearance(&stepped, &stepped, 0.5); + assert!(at(&mid, 0.25).0.abs() < 1e-9 && (at(&mid, 0.75).0 - 1.0).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure)); + // A whole turn recorded along a stroke stays a whole turn in the steps. + let mut turning = stroked(None, None, None); + if let Some(s) = turning.stroke_mut() { + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + } + let rot = |ap: &Appearance, f: f64| ap.stroke().and_then(|s| s.rotation.as_ref()).map_or(0.0, |p| p.at(f)); + let same = lerp_appearance(&turning, &turning, 0.5); + assert!((rot(&same, 0.25) - 90.0).abs() < 1e-9 && (rot(&same, 0.5).abs() - 180.0).abs() < 1e-9, "{:?}", same.stroke().map(|s| &s.rotation)); + let half = lerp_appearance(&turning, &plain, 0.5); + assert!((rot(&half, 0.5) - 90.0).abs() < 1e-9 && (rot(&half, 1.0).abs() - 180.0).abs() < 1e-9, "{:?}", half.stroke().map(|s| &s.rotation)); + // Neither key has a recording: the steps have none either. + let none = lerp_appearance(&plain, &plain, 0.5); + let s = none.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none()); + } + + #[test] + fn a_point_near_the_other_keys_step_keeps_the_step() { + let flat = PressureProfile { points: vec![(0.0, 0.0), (0.4999995, 0.0), (1.0, 0.0)] }; + let stepped = PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }; + let p = lerp_pressure(Some(&flat), Some(&stepped), 1.0).unwrap_or_default(); + assert!((p.at(0.75) - 1.0).abs() < 1e-9, "{p:?}"); + let narrow = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.4999995, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let stepped = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 0.0, 0.0), (1.0, 0.0, 0.0)] }; + let w = lerp_profile(Some(&narrow), Some(&stepped), 1.0).unwrap_or_default(); + assert!(w.at(0.75).0.abs() < 1e-9, "{w:?}"); + // The other key's step doesn't reshape a smooth profile's curve. + let early = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.25, 0.5, 0.5), (1.0, 0.5, 0.5)] }; + let w = lerp_profile(Some(&WidthProfile::lens()), Some(&early), 0.0).unwrap_or_default(); + for u in [0.1, 0.125, 0.2, 0.3, 0.4] { + let (got, want) = (w.at(u).0, WidthProfile::lens().at(u).0); + assert!((got - want).abs() < 5e-3, "at {u}: {got} for {want}: {w:?}"); + } + let lens = WidthProfile::lens(); + assert_eq!(lerp_profile(Some(&lens), Some(&lens), 0.5).and_then(|p| p.preset_id()), Some("lens")); + } + + #[test] + fn stroke_profiles_turn_with_a_key_path_started_elsewhere() { + let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + edit(s); + } + Node::path(NodeId(1), path, ap) + }; + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let pressure = |s: &StrokeLayer, f: f64| s.pressure.as_ref().map_or(PressureProfile::MID, |p| p.at(f)); + // An open key clicked at its last anchor runs the other way, and so do its pressure, its + // width profile (whose sides swap) and its arrowheads. + let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false)); + let light = keyed(line(0.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 0.0)] })); + let ramp = keyed(line(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 0.0)] }); + s.start_arrow = Some(Arrowhead::Triangle); + s.arrow_scale = (200.0, 100.0); + }); + let blend = Lerp::new(&light, &ramp, (None, Some(1))); + let s = stroke(&blend.at(0.25)); + assert!(pressure(&s, 0.0).abs() < 1e-9 && (pressure(&s, 1.0) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let s = stroke(&blend.at(1.0)); + assert_eq!(s.profile.as_ref().map(|p| p.at(1.0)), Some((0.0, 1.0)), "{:?}", s.profile); + assert_eq!((s.start_arrow, s.end_arrow, s.arrow_scale), (None, Some(Arrowhead::Triangle), (100.0, 200.0))); + // A key whose other subpaths keep their direction keeps its one stroke as it is. + let two = |x: f64| { + let sub = |y: f64| SubPath::polyline(&[Point::new(x, y), Point::new(x + 10.0, y)], false); + PathData::new(vec![sub(0.0), sub(20.0)]) + }; + let ramps = keyed(two(100.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] })); + let s = stroke(&Lerp::new(&keyed(two(0.0), &|_| {}), &ramps, (None, Some(1))).at(1.0)); + assert!((pressure(&s, 0.0) - 1.0).abs() < 1e-9, "{:?}", s.pressure); + // A closed key started half way round starts its recordings there too, keeping the step + // where its own start was and the whole turn its rotation makes. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)); + let plain = keyed(square(0.0), &|_| {}); + let turning = keyed(square(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }); + }); + let blend = Lerp::new(&plain, &turning, (Some(0), Some(2))); + let s = stroke(&blend.at(1.0)); + assert!((pressure(&s, 0.25) - 0.75).abs() < 1e-9 && (pressure(&s, 0.75) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let width = s.profile.as_ref().map_or((1.0, 1.0), |p| p.at(0.75)); + assert!((width.0 - 0.75).abs() < 1e-6 && (width.1 - 0.75).abs() < 1e-6, "the width steps where the key started: {:?}", s.profile); + // A width curve split by the new start keeps its shape. + let lensed = keyed(square(100.0), &|s| s.profile = Some(WidthProfile::lens())); + let s = stroke(&Lerp::new(&plain, &lensed, (Some(0), Some(1))).at(1.0)); + for u in [0.05, 0.125, 0.2, 0.6, 0.7, 0.74, 0.76, 0.8, 0.875, 0.95] { + let (got, want) = (s.profile.as_ref().map_or(1.0, |p| p.at(u).0), WidthProfile::lens().at((u + 0.25) % 1.0).0); + assert!((got - want).abs() < 2e-3, "at {u}: {got} for {want}: {:?}", s.profile); + } + let rot = |t: f64, f: f64| stroke(&blend.at(t)).rotation.as_ref().map_or(0.0, |p| p.unwrapped_at(f)); + assert!((vectorcraft_geom::normalize_deg(rot(1.0, 0.25)) + 90.0).abs() < 1e-6, "{:?}", s.rotation); + assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation); + } + #[test] fn odd_dash_blends_to_solid_with_closed_gaps() { let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2c5759f202..2ed800ceec 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -82,8 +82,8 @@ pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ - AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, - SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, + AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, CustomArrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, + ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use compound_shape::ShapeMode; diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 56405e0d18..047e1ad7b5 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -119,6 +119,12 @@ impl BlendSpec { pub fn start(&self, i: usize) -> Option { self.starts.get(i).copied().flatten().map(|a| a as usize) } + + /// Whether [`Self::ease`] moves the steps: the blend has a spine ([`Self::no_spine`] is off) + /// and a replaced spine or Align to Path, the cases where the steps are placed along it. + pub fn eases_along_spine(&self) -> bool { + !self.no_spine && (self.spine.is_some() || self.orientation == BlendOrientation::AlignToPath) + } } /// The Blend panel's Create Spine setting: whether a new blend gets a spine. diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 7c80866ee7..f4a601c7b5 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -119,7 +119,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { { out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); } - if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { + // A linked opacity mask bends with the object it masks; an unlinked one stays where it is. + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()).filter(|m| m.linked) { m.art = Arc::new(map_node(&m.art, w)); } match &mut out.kind { diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 8279507e01..8115d00954 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,6 +1,7 @@ //! Where a path's strokes paint, from the path's geometry without building their outlines: the //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes -//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points, +//! and a bevel ends at the corners of the two segments it joins. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; @@ -39,10 +40,20 @@ fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator Option<[Point; 3]> { - let cos = t_in.dot(t_out); // cos(half the turn) is sin(half the angle between the segments): the miter is side / it. - let half = ((1.0 + cos) / 2.0).max(0.0).sqrt(); - if cos >= CORNER_COS || half * limit < 1.0 { + let half = ((1.0 + t_in.dot(t_out)) / 2.0).max(0.0).sqrt(); + if half * limit < 1.0 { + return None; + } + let ([a, b], out) = bevel(at, t_in, t_out, side)?; + Some([a, at + out * (side / half), b]) +} + +/// The bevel at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its path: +/// the outer corners of the two segments' strokes and the unit direction out of the turn, or +/// `None` when the path doesn't turn there. +fn bevel(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<([Point; 2], Vec2)> { + if t_in.dot(t_out) >= CORNER_COS { return None; } let out = unit(t_in - t_out); @@ -51,7 +62,7 @@ fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[P let n = Vec2::new(-t.y, t.x); if n.dot(out) >= 0.0 { n } else { -n } }; - Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side]) + Some(([at + outer(t_in) * side, at + outer(t_out) * side], out)) } /// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as @@ -187,26 +198,44 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { } impl StrokeLayer { - /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, - /// an alignment that depends on whether the path is closed, or butt caps, which stop at the - /// ends of an open path? Else it reaches evenly round it. + /// Does bounding this stroke need its path's shape: miter spikes, bevels, projecting caps, + /// arrowheads, an alignment that depends on whether the path is closed, or butt caps, which + /// stop at the ends of an open path? Else it reaches evenly round it. fn needs_shape(&self) -> bool { - self.join == LineJoin::Miter + self.join != LineJoin::Round || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// The box this stroke paints along `segs`, the segments with length of an open subpath, when - /// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each - /// joint between segments paints ([`round_join_box`]), a shallow one included. - /// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips - /// are measured apart. A segment in the middle is measured only when its own box grown by - /// `side`, which holds everything it paints, is not inside the box so far. - fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option { + /// Does this stroke stop at the corners of its segments along a subpath that is `closed` or + /// open, where its caps are butt caps on an open subpath or its joins are bevels or miters? + /// Dashes with round or projecting caps can end at a corner, so with them only butt caps stop. + fn stops_at_corners(&self, closed: bool) -> bool { + let capped_dashes = self.cap != LineCap::Butt && self.dash.as_ref().is_some_and(Dash::is_dashed); + (self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes) + } + + /// Does this stroke reach past a subpath that is `closed` or open by a margin that follows the + /// direction of its segments at their corners: where it stops at them + /// ([`Self::stops_at_corners`]) or paints miter spikes, which dashes with round or projecting + /// caps also paint? + fn reaches_by_corners(&self, closed: bool) -> bool { + self.stops_at_corners(closed) || self.join == LineJoin::Miter + } + + /// The box this stroke's body paints along `segs`, the segments with length of a subpath, short + /// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an + /// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one + /// included. A bevel stays inside the boxes of the segments it joins. `None` when `segs` is + /// empty. Butt dashes stop inside the same box. The caps other than butt ones, the heads and + /// miter tips are measured apart. A segment in the middle is measured only when its own box + /// grown by `side`, which holds everything it paints, is not inside the box so far, which + /// starts as `within` and the first and last segments' boxes. + fn body_along(&self, segs: &[PathSeg], side: f64, within: Rect) -> Option { let (first, last) = (segs.first()?, segs.last()?); - let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)).union(within); let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| { if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) } }); @@ -217,24 +246,28 @@ impl StrokeLayer { } /// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt - /// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its - /// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of - /// projecting caps and the reach of the arrowheads round their end points. + /// caps, and every subpath when the joins are bevels or miters, by [`Self::body_along`] with its + /// caps, every other subpath by the box of its segments grown by its reach, with `tips` and, + /// when `measure_tips`, the miter spikes the limit keeps, the corners of projecting caps and the + /// reach of the arrowheads round their end points. fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option, measure_tips: bool) -> Rect { let els = bp.elements(); let subs = subpaths(bp); let mut out = tips.map_or(bounds, |t| bounds.union(t)); let mut side_max: f64 = 0.0; - // Every subpath reaches evenly unless one is open and butt-capped; only then is each - // subpath's own box needed. - let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1); - let mut even = even_only.then_some(bounds); // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); + // A butt cap stops at an open subpath's end, and a bevel, or a miter the limit bevels, at + // the two segments' corners, so such a subpath is measured along its segments. Every + // subpath reaches evenly unless one is measured that way; only then is each subpath's own + // box needed. + let even_only = !subs.iter().any(|s| self.stops_at_corners(s.1)); + let mut even = even_only.then_some(bounds); for (r, closed) in subs { let side = self.side_reach(closed); let butt = self.cap == LineCap::Butt && !closed; - if !butt { + let along = self.stops_at_corners(closed); + if !along { side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); } if side <= 0.0 { @@ -242,10 +275,10 @@ impl StrokeLayer { } let square = self.cap == LineCap::Square && !closed; let miters = measure_tips && self.join == LineJoin::Miter; - if !(butt || square || miters || !even_only) { + if !(along || square || miters || !even_only) { continue; } - let segs = drawn(&els[r]); + let segs = drawn(&els[r.clone()]); if miters { for (at, t_in, t_out) in joints(&segs, closed) { if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { @@ -253,13 +286,24 @@ impl StrokeLayer { } } } - if butt { - if let Some(body) = self.butt_body(&segs, side) { - out = out.union(body); + if along { + // The path's bounds are in the box already, so only the segments that can reach + // past them are measured. + match self.body_along(&segs, side, out) { + Some(body) => out = out.union(body), + // A subpath with no length paints at most its caps' dot. + None if !butt => { + let dot = segments(&els[r]).iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)); + out = dot.map_or(out, |d| out.union(d.inflate(side, side))); + } + None => {} } - continue; - } - if !even_only { + if self.cap == LineCap::Round && !closed { + for (at, _) in ends(&segs).into_iter().flatten() { + out = out.union(Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + } + } else if !even_only { even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); } if square { @@ -279,7 +323,8 @@ impl StrokeLayer { /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of /// projecting caps and the reach of the arrowheads round their end points. An open subpath - /// with butt caps does not reach past its ends ([`Self::butt_body`]). + /// with butt caps does not reach past its ends, and a bevel does not reach round its corner + /// ([`Self::body_along`]). pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { self.reach_along(bp, bounds, None, true) } @@ -329,6 +374,9 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); + // Past a butt cap or a bevel the stroke stops at the segments' corners. A width profile + // keeps the round reach at the ends. + let squared = self.profile.is_none() && self.stops_at_corners(closed); // Every segment near enough to reach `p`: with a width profile the wider side of a // farther segment can paint past the nearest one, so the nearest alone is not enough. let near = segs.iter().enumerate().filter_map(|(i, s)| { @@ -365,7 +413,8 @@ impl StrokeLayer { } }) }; - if d <= half * factor.max(0.0) + tol && on_side(region, d) { + if d <= half * factor.max(0.0) + tol && on_side(region, d) && (!squared || self.paints_at_end(&segs, closed, (i, nr.t), p, half, tol)) + { return true; } } @@ -393,6 +442,35 @@ impl StrokeLayer { false } + /// Does this stroke, where it stops at the corners of its segments ([`Self::stops_at_corners`]), + /// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs` + /// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of + /// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join + /// paints there, when the path does not turn at that end, or when `p` is within `tol` of the + /// bevel's triangle. Projecting caps and + /// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`]) + /// counts as painting round its ends. + fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool { + let (n, at_start) = (segs.len(), t <= 1e-9); + let Some(seg) = segs.get(i) else { return true }; + if (!at_start && t < 1.0 - 1e-9) || !(matches!(seg, PathSeg::Line(_)) || gentle(seg, side)) { + return true; + } + let (at, out) = if at_start { (seg.start(), -tangent(seg, 0.0)) } else { (seg.end(), tangent(seg, 1.0)) }; + if (p - at).dot(out) <= tol { + return true; + } + // The segment on the other side of this end, `None` at the end of an open subpath. + let other = if at_start { (i > 0 || closed).then(|| (i + n - 1) % n) } else { (i + 1 < n || closed).then(|| (i + 1) % n) }; + let Some(other) = other.and_then(|o| segs.get(o)) else { return self.cap == LineCap::Round }; + if self.join == LineJoin::Round { + return true; + } + let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) }; + // A joint where the path does not turn has no bevel: the stroke runs on through it. + bevel(at, t_in, t_out, side).is_none_or(|([a, b], _)| in_convex(&[at, a, b], p, tol)) + } + /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path /// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and /// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With @@ -400,13 +478,15 @@ impl StrokeLayer { /// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the /// chord the path bends through under it, so anywhere within its length of the end. fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool { - let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false }; + let Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false }; let (hw, v) = (self.arrow_weight(end), p - at); match self.arrow_align { ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol, ArrowAlign::Extend => { let along = v.dot(out); - v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol + v.cross(out).abs() <= kind.half_width() * hw + tol + && along <= kind.length() * hw + self.width / 2.0 + tol + && along >= -kind.reach() * hw - tol } } } @@ -426,7 +506,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, bevels, projecting or butt caps, arrowheads or an alignment) given by `shaped`. pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { @@ -437,17 +517,21 @@ impl Appearance { }) } - /// Does this appearance have a visible butt-capped stroke? - pub fn has_butt_stroke(&self) -> bool { - self.painted_strokes().any(|s| s.cap == LineCap::Butt) + /// Does this appearance have a visible stroke that stops at the corners of its segments on an + /// open subpath or a closed one, or paints miter spikes: butt caps, or bevel and miter joins + /// ([`StrokeLayer::reaches_by_corners`])? + pub fn has_cornered_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.reaches_by_corners(false)) } - /// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with - /// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The - /// visual box's margin at the stroke's corners there depends on their direction, which a scale - /// out of proportion changes. - pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { - if !self.has_butt_stroke() { + /// Does a visible stroke of this appearance that stops at the corners of its segments (butt + /// caps on an open subpath, bevel and miter joins) or paints miter spikes, dashed ones with + /// round or projecting caps included, run along a subpath of `bp` where it does so, with a + /// segment that starts or ends at a slant, not square to the axes, once placed by `a`? The + /// visual box's margin at those corners, and at miter tips, depends on their direction, which a + /// scale out of proportion changes. + pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_cornered_stroke() { return false; } let slanted = |v: Vec2| { @@ -455,9 +539,10 @@ impl Appearance { (d.x * d.y).abs() > 1e-9 }; let els = bp.elements(); - subpaths(bp) - .into_iter() - .filter(|(_, closed)| !closed) + let subs = subpaths(bp); + let cornered = |closed: bool| self.painted_strokes().any(|s| s.reaches_by_corners(closed)); + subs.into_iter() + .filter(|(_, closed)| cornered(*closed)) .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) } @@ -468,28 +553,28 @@ impl Appearance { } } -/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// How many symbol instances [`crate::Node::has_slanted_stroke_corners`] looks into for one object /// before it answers yes without looking further. pub const SLANT_INSTANCES: usize = 4096; impl crate::Node { - /// Does a visible butt-capped stroke of this object, of an object in it or of an object in a - /// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment - /// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it - /// answers yes. - pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { - self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) + /// Does a visible stroke of this object, of an object in it or of an object in a symbol + /// instance's art from `symbols`, placed by `a`, stop at the corners of a segment at a slant + /// ([`Appearance::has_slanted_stroke_corners`])? Past [`SLANT_INSTANCES`] instances it answers + /// yes. + pub fn has_slanted_stroke_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_corners(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) } - /// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into. - fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { + /// [`Self::has_slanted_stroke_corners`] with `left` instances still to look into. + fn slanted_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind { let Some(l) = left.get().checked_sub(1) else { return true }; left.set(l); - return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false); + return symbols.measure_art(symbol, |art| art.slanted_corners(symbols, a * *xf, left)).unwrap_or(false); } - (self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a))) - || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left))) + (self.appearance.has_cornered_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_stroke_corners(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_corners(symbols, a, left))) } } @@ -566,6 +651,79 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + #[test] + fn a_bevel_stops_at_the_corners_of_the_segments_it_joins() { + // A 40 × 100 pt triangle stroked 20 pt: a miter limit of 2 or a bevel join bevels its + // 22.6° tip at (20, 0), so the stroke reaches 10 · 20 / √10400 pt above it, not 10 pt. + let mut tri = BezPath::new(); + tri.move_to((0.0, 100.0)); + tri.line_to((20.0, 0.0)); + tri.line_to((40.0, 100.0)); + tri.close_path(); + let tb = tri.bounding_box(); + let (top, side) = (-200.0 / 10400.0_f64.sqrt(), 1000.0 / 10400.0_f64.sqrt()); + let limited = stroke(20.0, |s| s.miter_limit = 2.0); + let bevel = stroke(20.0, |s| { + s.join = LineJoin::Bevel; + s.cap = LineCap::Round; + }); + let b = limited.bounds_on(&tri, tb); + assert!((b.y0 - top).abs() < 1e-9, "{b:?}"); + // The limit keeps the base corners' miters, whose tips reach about 12.2 pt left and right. + assert!(b.x0 < -12.0 && b.x1 > 52.0, "{b:?}"); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 20.0); + *ap.stroke_mut().unwrap() = bevel.clone(); + let b = ap.stroked_bounds(tb, || tri.clone()); + for (got, want) in [(b.x0, -side), (b.y0, top), (b.x1, 40.0 + side), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // Open, the same corner is beveled and the round caps reach the half weight round the ends. + let chevron = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + let b = bevel.bounds_on(&chevron, chevron.bounding_box()); + for (got, want) in [(b.x0, -10.0), (b.y0, top), (b.x1, 50.0), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // A round join, and the round caps of dashes, which can end at the tip, reach round it. + let round = stroke(20.0, |s| s.join = LineJoin::Round); + assert!((round.bounds_on(&tri, tb).y0 + 10.0).abs() < 1e-9); + let mut dashed = bevel.clone(); + dashed.dash = Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false }); + assert!((dashed.bounds_on(&chevron, chevron.bounding_box()).y0 + 10.0).abs() < 1e-9); + } + + #[test] + fn a_click_past_a_bevel_or_a_butt_end_misses() { + // 5 pt above the triangle's tip is within the half weight of it, but past the bevel, which + // reaches about 1.96 pt above it. A round join paints there. + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + let (above, on) = (Point::new(20.0, -5.0), Point::new(20.0, -1.5)); + for join in [LineJoin::Bevel, LineJoin::Miter] { + let st = stroke(20.0, |s| (s.join, s.miter_limit) = (join, 2.0)); + assert!(!st.hits(&tri, FillRule::NonZero, above, 0.5), "{join:?}"); + assert!(st.hits(&tri, FillRule::NonZero, on, 0.0), "{join:?}"); + } + assert!(stroke(20.0, |s| s.join = LineJoin::Round).hits(&tri, FillRule::NonZero, above, 0.5)); + // 1.5 pt past a butt end of a 4 pt line misses, and a quarter point within it hits; a round + // cap paints past it. + let line = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + let butt = stroke(4.0, |s| s.cap = LineCap::Butt); + assert!(!butt.hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + assert!(butt.hits(&line, FillRule::NonZero, Point::new(1.0, -0.2), 0.25)); + assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + } + + #[test] + fn a_joint_too_shallow_to_bevel_paints_past_both_segments_ends() { + // The path turns by about 0.57° at (100, -0.5), too little to bevel, so the stroke runs on + // through the joint: 3.5 pt above it is past the end of both segments and still painted. + let kink = polyline(&[(0.0, 0.0), (100.0, -0.5), (200.0, 0.0)]); + let above = Point::new(100.0, -4.0); + for join in [LineJoin::Bevel, LineJoin::Miter, LineJoin::Round] { + assert!(stroke(10.0, |s| s.join = join).hits(&kink, FillRule::NonZero, above, 0.0), "{join:?}"); + } + } + /// An open path through `points`. fn polyline(points: &[(f64, f64)]) -> BezPath { let mut bp = BezPath::new(); @@ -691,15 +849,25 @@ mod tests { let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); ap.stroke_mut().unwrap().cap = LineCap::Butt; let id = vectorcraft_geom::Affine::IDENTITY; - assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); + assert!(!ap.has_slanted_stroke_corners(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); // Square ends, slanted segments in the middle. let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&apex, id)); - // A rotation slants a vertical line; a round cap is never slanted. + assert!(ap.has_slanted_stroke_corners(&apex, id)); + // A rotation slants a vertical line. let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + assert!(ap.has_slanted_stroke_corners(&v, vectorcraft_geom::Affine::rotate(0.3))); + // With round caps, the miter join still stops at the corners of the slanted segments, on a + // closed path too; round caps and round joins never do. ap.stroke_mut().unwrap().cap = LineCap::Round; - assert!(!ap.has_slanted_butt_stroke(&apex, id)); + assert!(ap.has_slanted_stroke_corners(&apex, id)); + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + ap.stroke_mut().unwrap().join = LineJoin::Bevel; + assert!(ap.has_slanted_stroke_corners(&tri, id)); + assert!(!ap.has_slanted_stroke_corners(&square(), id)); + ap.stroke_mut().unwrap().join = LineJoin::Round; + assert!(!ap.has_slanted_stroke_corners(&apex, id)); + assert!(!ap.has_slanted_stroke_corners(&tri, id)); } #[test] @@ -723,11 +891,11 @@ mod tests { } let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY }); let symbols = d.symbol_boxes(); - assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY)); + assert!(top.has_slanted_stroke_corners(&symbols, vectorcraft_geom::Affine::IDENTITY)); assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured()); // One instance of S1 holds three instances, all looked into. let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY }); - assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); + assert!(!one.has_slanted_stroke_corners(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); } #[test] diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 37c76c5e3a..fcaa78e494 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -57,7 +57,9 @@ pub struct CharStyle { #[serde(default)] pub all_caps: bool, /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns - /// a feature on, `"-liga"` off. + /// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's + /// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that + /// differ only in this list are shaped together, each with its own features. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub features: Vec, /// Character style (Character Styles panel) these attributes come from; None = Normal. diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index bb3725072e..a2c2a66498 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -237,6 +237,10 @@ pub struct ParaStyle { /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. pub kinsoku_type: KinsokuShoriType, + /// Bunri-Kinshi (Paragraph panel menu): a line never breaks inside an inseparable pair (two em + /// dashes, two ellipses, two two-dot leaders, a vertical kana repeat mark's two halves). Kept + /// with kinsoku None, where it has no effect. Off by default. + pub bunri_kinshi: bool, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -339,6 +343,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku_type: KinsokuShoriType, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + bunri_kinshi: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -375,6 +381,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -401,6 +408,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -431,6 +439,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -456,6 +465,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 795fc69bdf..24f3d68af6 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { fn clear_item_effects(item: &mut AppearanceItem) { match item { - AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)), - AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)), + AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), + AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), } } @@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option { g.knockout = Knockout::Off; g.knockout_shape = n.knockout_shape; g.extras_mut().mask = n.extras().mask.clone(); - g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(g) } @@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { return None; } reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; Some(m) @@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); Some((g, rule, ctx, m)) } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 84991392b2..031d3e1d98 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,6 +94,8 @@ fn lengths_of(id: &str) -> Lengths { "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), "brushStrokes.accentedEdges" => always(&["edgeWidth"]), "artistic.paletteKnife" => always(&["strokeSize"]), + "artistic.coloredPencil" => always(&["pencilWidth"]), + "artistic.paintDaubs" => always(&["brushSize"]), "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), @@ -258,14 +260,14 @@ pub fn effect_catalog() -> Vec { "distort.roughen", "Roughen…", DT, - "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + "{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), ), g( "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( @@ -387,7 +389,7 @@ pub fn effect_catalog() -> Vec { "artistic.paintDaubs", "Paint Daubs…", ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + "{brushSize: 1..50 (10; spacing of the dab centers in document units),sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), ), r( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index c070457027..a5bc941b16 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -3,6 +3,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; +use vectorcraft_pathops::addons::pathtools::insert_at; use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points}; @@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000; /// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where /// that would make more than `max` anchors, the points are spaced further apart until it doesn't -/// (stacked Roughens multiply their points, #3578). +/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is +/// split into its pieces, evenly along its length, and the outline stays as it is. pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let size = num(p, "size", 5.0).clamp(0.0, 1e4); let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size }; @@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect(); let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count())); let mut lens = lens.iter(); + if amp == 0.0 { + // Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them. + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let mut places = vec![]; + for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) { + let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS); + let c = sp.segment(i); + let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY); + if len > 1e-9 && len.is_finite() { + places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY)))); + } + } + subs.push(insert_at(sp, &places).0); + } + return PathData::new(subs); + } let smooth = smooth_points(p); let seed = seed(p); let mut subs = vec![]; @@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { PathData::new(subs) } +const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4; + /// The move and the number of copies of a Transform effect with params `p` that only moves what /// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects, /// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 57f2fc8613..015490a74d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { fn without_geometry_effects(n: &mut Node) { if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { let mut ap = (*n.appearance).clone(); - ap.effects.retain(|e| !crate::is_geometry(&e.id)); + ap.effects = crate::raster::without_geometry(&ap.effects); n.appearance = ap.into(); } } @@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { carry.apply(&mut m.appearance); } } - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); if paint { let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 16e893ce99..a7fb05b2a6 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -690,6 +690,525 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { } } +/// A white 10 pt square with id `id` at the origin. +fn white_square(id: u64) -> Node { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)) +} + +/// `n` masked by `art`, linked or not. +fn masked_by(mut n: Node, art: Node, linked: bool) -> Node { + let mut mask = vectorcraft_doc::OpacityMask::new(art, true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n +} + +/// The geometry effects that reshape a group's members, one at a time. +fn reshaping_effects() -> Vec { + let fx = |id: &str, params: serde_json::Value| Effect { id: id.into(), params, visible: true }; + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + vec![ + fx("distort.transform", serde_json::json!({"moveH": 30})), + fx("distort.transform", serde_json::json!({"rotate": 90})), + fx("distort.twist", serde_json::json!({"angle": 90})), + fx("distort.freeDistort", corners), + fx("warp.arc", serde_json::json!({})), + fx("distort.roughen", serde_json::json!({"size": 5, "seed": 3})), + fx("distort.zigZag", serde_json::json!({})), + fx("distort.puckerBloat", serde_json::json!({"amount": 50})), + fx("distort.tweak", serde_json::json!({})), + fx("path.offsetPath", serde_json::json!({"offset": 3})), + fx("stylize.roundCorners", serde_json::json!({"radius": 3})), + ] +} + +/// A group's geometry effect reshapes the art of a member's linked opacity mask as it reshapes the +/// member: a mask drawn over the member still covers it. An unlinked mask stays where it is. +#[test] +fn a_groups_geometry_effects_reshape_a_members_linked_mask_with_it() { + let square = white_square(1); + let original = square.path_data().cloned(); + for e in reshaping_effects() { + for linked in [true, false] { + let member = masked_by(square.clone(), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = member.extras().mask.as_deref().unwrap(); + assert!(member.path_data().cloned() != original, "{}: the member is reshaped", e.id); + let want = if linked { member.path_data().cloned() } else { original.clone() }; + assert_eq!(mask.art.path_data().cloned(), want, "{} (linked: {linked})", e.id); + assert_eq!(mask.linked, linked); + } + } +} + +/// The mask of a group inside the group, the mask of a path in it, a mask inside that mask's art and +/// an inverted mask all take the outer group's effect with the paths they mask. +#[test] +fn a_groups_geometry_effects_reshape_nested_and_inverted_masks() { + for e in reshaping_effects() { + let innermost = masked_by(white_square(7), white_square(8), true); + let path = masked_by(white_square(1), masked_by(white_square(6), innermost, true), true); + let mut inner = masked_by(Node::group(NodeId(5), vec![Arc::new(path)]), white_square(4), true); + if let Some(m) = inner.extras_if_any().and_then(|x| x.mask.as_mut()) { + m.invert = true; + } + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let path = &inner.children().unwrap()[0]; + let want = path.path_data().cloned(); + let mask_of = |n: &Node| n.extras().mask.as_deref().map(|m| (*m.art).clone()).unwrap(); + let (outer_mask, path_mask) = (mask_of(inner), mask_of(path)); + assert!(inner.extras().mask.as_deref().is_some_and(|m| m.invert)); + assert_eq!(outer_mask.path_data().cloned(), want, "{}: the inner group's inverted mask", e.id); + assert_eq!(path_mask.path_data().cloned(), want, "{}: the path's mask", e.id); + assert_eq!(mask_of(&path_mask).path_data().cloned(), want, "{}: the mask inside the mask's art", e.id); + assert_eq!(mask_of(&mask_of(&path_mask)).path_data().cloned(), want, "{}: one level deeper", e.id); + } +} + +/// A Transform effect's copies of a path with a linked mask are objects of their own, each with the +/// mask moved with it; the original keeps its mask where it was. +#[test] +fn each_transform_copy_of_a_masked_member_carries_its_mask() { + let member = masked_by(white_square(1), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for c in copies { + let mask = c.extras().mask.as_deref().unwrap(); + assert_eq!(mask.art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect's copies of the paths of a member group give the group's linked mask the same +/// copies: the mask covers each copy. +#[test] +fn a_member_groups_mask_takes_the_transform_copies_of_its_paths() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.geometric_bounds(); + assert_eq!(want, Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "the path and its copy"); + assert_eq!(inner.extras().mask.as_deref().unwrap().art.geometric_bounds(), want); +} + +/// A mask's art of several objects takes Transform copies whole, as the art it masks does: each copy +/// of the mask's art is a group of its objects, so the copies do not interleave object by object. +#[test] +fn a_masks_art_of_several_objects_is_copied_whole() { + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let copies = mask.children().unwrap(); + assert_eq!(copies.len(), 2, "the mask's art and its copy"); + for c in copies { + let ids: Vec = c.children().into_iter().flatten().map(|m| m.id).collect(); + assert_eq!(ids, [NodeId(4), NodeId(5)], "each copy holds the mask's objects"); + } + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0))); +} + +/// The linked mask of a mask's art that is copied whole takes the same Transform copies. +#[test] +fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() { + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)), "the mask's art and its copy"); + let own = &mask.extras().mask.as_deref().expect("the mask's art keeps its own mask").art; + assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too"); +} + +/// The mask of a mask's art copied whole stays in the budget while that art is copied: when the +/// budget has no room for that mask's copy, the art keeps its shape with its masks. +#[test] +fn the_mask_of_a_masks_art_copied_whole_counts_against_the_budget() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut art = Node::group(NodeId(10), vec![Arc::new(inner)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the four squares and the copies of three of them, not for the copy of the fourth. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 7 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let inner = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(inner.geometric_bounds(), square, "the path keeps its shape"); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 15.0, 10.0)), "and so does its mask"); +} + +/// When a member group's linked mask can't take all the Transform copies its paths take (past the +/// budget), the art keeps its shape under the effect, so the mask still covers the paths. +#[test] +fn art_whose_mask_would_take_fewer_copies_keeps_its_shape() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let member = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and a copy of the path, not for a copy of the mask. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 3 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let member = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(member.geometric_bounds(), square, "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), square, "and so does its mask"); +} + +/// The copies of a path with a linked mask count the art of the mask each copy carries against the +/// budget: they stop where the masks would pass it. +#[test] +fn transform_copies_count_the_art_of_their_masks() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 2}), visible: true }; + let member = masked_by(white_square(1), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and one copy of both, not for a second copy. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 4 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let copies = art.children().unwrap()[0].children().expect("the object and its copy are objects of their own"); + assert_eq!(copies.len(), 2); + for c in copies { + assert_eq!(c.extras().mask.as_deref().unwrap().art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect that scales strokes moves a group's paths as objects, their masks with them. +/// The mask of a member group, whose art holds a path with a mask of its own, moves with the group's +/// paths too. +#[test] +fn a_transform_that_moves_paths_as_objects_moves_a_member_groups_nested_mask() { + let mask_art = masked_by(white_square(4), white_square(5), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(3, 20.0, Color::BLACK)]); + let params = serde_json::json!({"moveH": 30, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.children().unwrap()[0].geometric_bounds(); + assert_eq!(want, Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), want, "the group's mask"); + assert_eq!(mask.extras().mask.as_deref().unwrap().art.geometric_bounds(), want, "the mask inside its art"); +} + +/// A group's Transform effect moves an image member's linked opacity mask with the image, and its +/// copies' masks with the copies; an unlinked mask stays. +#[test] +fn a_groups_transform_moves_an_image_members_linked_mask() { + for linked in [true, false] { + for copies in [0, 2] { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(image)]); + let params = serde_json::json!({"moveH": 30, "copies": copies}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let first = &art.children().unwrap()[0]; + let images: Vec<&Node> = match first.children() { + Some(ch) => ch.iter().map(|c| &**c).collect(), + None => vec![first], + }; + assert_eq!(images.len(), copies + 1); + for im in images { + let mask = im.extras().mask.as_deref().unwrap(); + let want = if linked { im.geometric_bounds() } else { Some(Rect::new(0.0, 0.0, 10.0, 10.0)) }; + assert_eq!(mask.art.geometric_bounds(), want, "linked {linked}, copies {copies}"); + } + } + } +} + +/// When a path in a member's mask art would pass the effects' budget, the whole mask stays as it +/// was, rather than part of its art moving with the member, and the member keeps its shape with it. +#[test] +fn a_mask_whose_art_passes_the_budget_stays_whole() { + // Bloat bends a path's sides into curves drawn with more lines the larger they are: many more + // on the mask's large square. + let bloat = Effect { id: "distort.puckerBloat".into(), params: serde_json::json!({"amount": 50}), visible: true }; + let large = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), Appearance::default()); + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), Arc::new(large.clone())]); + let member = masked_by(white_square(1), mask_art.clone(), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + // Room for the member and the mask's small square to take the effect, not for the large one. + let bounds = art.geometric_bounds().unwrap(); + let ctx = crate::GeomContext { seed: 10, ..crate::GeomContext::of(&art) }; + let square = white_square(1).path_data().cloned().unwrap(); + let made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), &square, bounds, &ctx)).lines; + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 2 * made + crate::Budget::of(large.path_data().unwrap()).lines }; + let large_made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), large.path_data().unwrap(), bounds, &ctx)).lines; + assert!(large_made > crate::Budget::of(large.path_data().unwrap()).lines, "the large square's curves cost more lines"); + crate::reshape::reshape_leaves_within(&mut art, &[&bloat], budget, None); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), Some(&square), "the member keeps its shape with its mask"); + assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole"); +} + +/// Live objects in a member's linked mask are evaluated before the group's geometry effects run +/// over the mask's art, so the mask still covers the member: a Repeat in it moves and bends with +/// the member. +#[test] +fn a_groups_effects_reshape_a_live_object_in_a_members_mask() { + let mut grid = vectorcraft_doc::RepeatSpec::grid(vec![Arc::new(white_square(5))], 0.0, 0.0); + if let vectorcraft_doc::RepeatKind::Grid { rows, cols, .. } = &mut grid.kind { + (*rows, *cols) = (1, 1); + } + let repeat = Node::new(NodeId(4), NodeKind::Repeat(grid)); + let drawn: Vec = crate::expand_live(None, &repeat); + let leaf = |n: &Node| { + let mut found = None; + n.walk(&mut |c| found = found.take().or_else(|| c.path_data().cloned())); + found + }; + assert_eq!(drawn.iter().find_map(leaf), white_square(1).path_data().cloned(), "the Repeat draws one square over the member"); + for e in [reshaping_effects()[0].clone(), reshaping_effects()[4].clone()] { + let member = masked_by(white_square(1), repeat.clone(), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert!(member.path_data() != white_square(1).path_data(), "{}: the member is reshaped", e.id); + assert_eq!(leaf(mask), member.path_data().cloned(), "{}: the mask's square takes the effect with it", e.id); + } +} + +/// Type in a member's linked mask is outlined before the group's Transform effect moves the mask's +/// art with the member. +#[test] +fn a_groups_transform_moves_type_in_a_members_mask() { + if skip_without_craft_fonts() { + return; + } + let member = masked_by(white_square(1), type_h(), true); + let before = crate::reshape::outline_text(&type_h()).unwrap().geometric_bounds().unwrap(); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let mut text = false; + mask.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_))); + assert!(!text, "the type is outlined"); + let after = mask.geometric_bounds().unwrap(); + assert!((after.x0 - before.x0 - 30.0).abs() < 0.5 && (after.y0 - before.y0).abs() < 0.5, "{before:?} -> {after:?}"); +} + +/// The linked mask of a compound path in a group, and of a path in a layer, takes the geometry +/// effects with the art it masks. +#[test] +fn a_compound_paths_and_a_layers_members_masks_take_the_effects() { + let geometry = |n: &Node| crate::reshape::leaf_geometry(n).map(|(p, _)| p); + for e in reshaping_effects() { + let square = white_square(1); + let compound = Node::new(NodeId(1), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: FillRule::NonZero }); + let compound = masked_by(compound, white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(compound), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(geometry(member) != square.path_data().cloned(), "{}: the compound path is reshaped", e.id); + assert_eq!(geometry(&member.extras().mask.as_deref().unwrap().art), geometry(member), "{}: compound path", e.id); + + let mut layer = Node::layer(NodeId(10), "Layer", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + ch.extend([Arc::new(masked_by(square.clone(), white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + } + layer.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &layer).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(member.path_data() != square.path_data(), "{}: the layer's path is reshaped", e.id); + assert_eq!(member.extras().mask.as_deref().unwrap().art.path_data(), member.path_data(), "{}: layer", e.id); + } +} + +/// A group's Transform effect moves a symbol instance member's linked mask with the instance. +#[test] +fn a_groups_transform_moves_a_symbol_instance_members_linked_mask() { + let instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(instance, white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let instance = &art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {instance:?}") }; + assert!((xf.translation().x - 30.0).abs() < 1e-9, "the instance moves: {xf:?}"); + let mask = instance.extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask.x0 - 30.0).abs() < 1e-9 && (mask.x1 - 40.0).abs() < 1e-9, "its mask moves with it: {mask:?}"); +} + +/// With Scale Strokes & Effects on, a group's Transform effect scales the stroke of an image +/// member's linked mask as it scales that of a path member's mask. +#[test] +fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() { + let stroked = + Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)); + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), stroked.clone(), true); + let path = masked_by(white_square(2), stroked, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(image), Arc::new(path)]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let width = |n: &Node| n.extras().mask.as_deref().unwrap().art.appearance.stroke().map(|s| s.width); + let ch = art.children().unwrap(); + assert_eq!(width(&ch[1]), Some(4.0), "the path's mask"); + assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask"); +} + +/// With Transform Objects off and Scale Strokes & Effects on, a group's Transform effect scales the +/// strokes of a member's linked mask as it scales the member's, and moves neither. +#[test] +fn a_transform_of_strokes_only_scales_a_members_mask_strokes() { + let stroked = |id: u64| { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)) + }; + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(stroked(1), stroked(4), true))]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert_eq!(member.appearance.stroke().map(|s| s.width), Some(4.0), "the member's stroke"); + assert_eq!(mask.appearance.stroke().map(|s| s.width), Some(4.0), "the mask's stroke"); + assert_eq!(mask.geometric_bounds(), member.geometric_bounds()); +} + +/// The art of a member group's mask is copied as it was before the first effect: a mask of one +/// path takes a second Transform effect's copies path by path, as the path it masks does, though +/// the first effect (with Scale Strokes & Effects) made it a group of copies. +#[test] +fn a_masks_art_is_copied_as_it_was_before_the_first_effect() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + for strokes in [true, false] { + let params = serde_json::json!({"moveH": 30, "copies": 2, "strokes": strokes}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + } + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds()); + let kinds = |n: &Node| n.children().unwrap().iter().map(|c| std::mem::discriminant(&c.kind)).collect::>(); + let paths = &inner.children().unwrap()[0]; + assert_eq!(kinds(paths).len(), 3, "the first effect's three paths"); + assert!(paths.children().unwrap().iter().all(|c| matches!(c.kind, NodeKind::Compound { .. })), "each with its copies"); + assert_eq!(kinds(mask), kinds(paths), "the mask's art takes the copies as the paths do"); +} + +/// A 10 × 10 image with id `id` at the origin. +fn image_node(id: u64) -> Node { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(NodeId(id), NodeKind::Image(Box::new(im))) +} + +/// A linked mask whose art is an image moves with the path it masks under a group's Transform +/// effect, and a member group's image mask takes the copies its paths take. +#[test] +fn a_groups_transform_moves_a_linked_mask_whose_art_is_an_image() { + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(white_square(1), image_node(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.geometric_bounds(), Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), member.geometric_bounds()); + + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), image_node(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + assert_eq!(inner.geometric_bounds(), Some(Rect::new(0.0, 0.0, 70.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds(), "the image and its copies"); +} + +/// An image in a linked mask's art takes no geometry effect but Transform, so under a Warp effect +/// the art keeps its shape with its mask. +#[test] +fn art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp() { + let member = masked_by(white_square(1), Node::group(NodeId(3), vec![Arc::new(image_node(4))]), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[4].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), white_square(1).path_data(), "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); +} + +/// A Warp effect bends an image's pixels but not the art of its linked mask, so art with such an +/// image keeps its shape under the warp, with the image and its mask. +#[test] +fn art_with_a_masked_image_keeps_its_shape_under_a_warp() { + let image = masked_by(image_node(1), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(image.clone()), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[4].clone()); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert_eq!(*ch[0], image, "the image and its mask"); + assert_eq!(ch[1].path_data(), sq(2, 20.0, Color::BLACK).path_data(), "the path keeps its shape"); +} + +/// A symbol instance in perspective projects the linked masks of the objects in its art with them. +#[test] +fn perspective_projects_the_masks_in_a_symbols_art() { + let art = Node::group(NodeId(20), vec![Arc::new(masked_by(white_square(21), white_square(22), true))]); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut at = vectorcraft_doc::PerspectiveAttachment::new("left", 0.0); + at.projection = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.01, 0.0, 1.0]); + instance.extras_mut().perspective = Some(Box::new(at)); + let out = crate::reshape::reshape(None, &instance, Some(&art)).unwrap(); + let mut masked = vec![]; + out.walk(&mut |c| masked.extend(c.extras().mask.as_deref().map(|m| (c.path_data().cloned(), m.art.path_data().cloned())))); + let [(path, mask)] = masked.as_slice() else { panic!("one masked path: {masked:?}") }; + assert!(path != &white_square(21).path_data().cloned(), "the path is projected"); + assert_eq!(mask, path, "and so is its mask"); +} + /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit /// pitch, each crossing every bar of the other direction: n² crossings. fn crossing_bars(n: usize, effect: &str) -> Node { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8591bbab2c..080098ad24 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, @@ -89,8 +89,8 @@ pub use revolve::{ pub use solids::{ CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, - three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, - validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, + validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index cfa51ca2b0..07140e2bc9 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,11 +1,13 @@ //! Raster (painted) effects: descriptions for the renderer. -use serde_json::Value; +use std::borrow::Cow; + +use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 { (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 } -/// The visible raster effects of `effects`, in stack order. +/// The visible raster effects of `effects`, in stack order, each following the Transform effects +/// listed after it ([`followed`]). pub fn raster_effects(effects: &[Effect]) -> Vec { - effects + followed(effects) .iter() .filter(|e| e.visible) .filter_map(|e| { @@ -426,6 +429,116 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { .collect() } +/// `effects` with each raster effect following the visible Transform effects listed after it +/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect +/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says. +/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform +/// effect has been expanded. A raster effect that a Transform effect making copies or moving at +/// random follows stays as it is, as does one after the last Transform effect. Borrowed when +/// nothing changes. +pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { + if !effects.iter().any(|e| e.visible && e.id == "distort.transform") { + return Cow::Borrowed(effects); + } + let mut out: Option> = None; + // The linear part of the Transform effects after the effect at hand, the last one outermost; + // `None` once one of them makes copies or moves at random. + let mut after = Some(Affine::IDENTITY); + for (i, e) in effects.iter().enumerate().rev() { + if e.id == "distort.transform" { + after = after.and_then(|a| transform_step(e).map(|s| a * s)); + continue; + } + let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; + let list = out.get_or_insert_with(|| effects.to_vec()); + if let Some(e) = list.get_mut(i) { + follow_transform(e, a); + } + } + out.map_or(Cow::Borrowed(effects), Cow::Owned) +} + +/// `effects` without their geometry effects, each raster effect following the Transform effects +/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects +/// are applied to its art. +pub fn without_geometry(effects: &[Effect]) -> Vec { + followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() +} + +/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as +/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false` +/// when `t` leaves them as they are. +pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool { + let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false }; + let mut changed = false; + for e in effects.iter_mut().filter(|e| follows(&e.id)) { + follow_transform(e, a); + changed = true; + } + changed +} + +/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the +/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform +/// effect among them that makes copies or moves at random. +pub(crate) fn held_back(effects: &[Effect]) -> usize { + let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 }; + effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count() +} + +/// Does `e` stop the raster effects listed before it from following the Transform effects after +/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random? +pub(crate) fn holds_back(e: &Effect) -> bool { + transform_step(e).is_none() +} + +/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map +/// when it is a visible Transform effect that moves the art once, the identity when it is no +/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None` +/// when it makes copies or moves at random. +fn transform_step(e: &Effect) -> Option { + if e.id != "distort.transform" || !e.visible { + return Some(Affine::IDENTITY); + } + let p = merged_params(&e.id, &e.params); + if !flag(&p, "objects", true) { + return Some(Affine::IDENTITY); + } + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) +} + +/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG +/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room +/// around the art. +fn follows(id: &str) -> bool { + crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE +} + +/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as +/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean +/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with +/// `a`. Effects that [`follows`] leaves out stay as they are. +fn follow_transform(e: &mut Effect, a: Affine) { + if !follows(&e.id) { + return; + } + let offset = (e.id == "stylize.dropShadow").then(|| { + let p = merged_params(&e.id, &e.params); + a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) + }); + if let Some(k) = vectorcraft_doc::Scaling::factor(a) { + crate::scale_effect(e, k); + } + if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params["x"] = json!(o.x); + e.params["y"] = json!(o.y); + } +} + /// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) /// geometry's, in document units. Geometry effects are accounted for by evaluating them. pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 9d93f82a79..8822548c29 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -12,7 +12,7 @@ //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -590,7 +590,7 @@ fn as_art_opts( transform_items(&mut art, paint_doc); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { - project_leaves(&mut art, &h); + project_leaves(&mut art, &h, paint_doc); } // The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip // group). @@ -604,13 +604,33 @@ fn as_art_opts( out.knockout = vectorcraft_doc::Knockout::Off; out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(out) } -/// Map every path under `art` (and its gradients) through the projective map `h`, curves split -/// finely enough to follow it. -fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) { +/// Map every path under `art` (and its gradients), and every path in the art of the linked opacity +/// masks of the objects under it, through the projective map `h`, curves split finely enough to +/// follow it. The type and live objects in the art of those masks are outlined and evaluated +/// first ([`outlined_members`], with the symbols of `doc`). +fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography, doc: Option<&Document>) { + project_paths(art, h); + each_linked_mask_mut(art, false, 0, &mut |m| { + *m = outlined_mask_art(doc, m); + project_paths(m, h); + }); +} + +/// `art`, the art of an opacity mask, with type and live objects in it outlined and evaluated +/// ([`outlined_members`]), and as art that keeps its look ([`as_art`]) when it is type itself. +fn outlined_mask_art(doc: Option<&Document>, art: &Node) -> Node { + match art.kind { + NodeKind::Text(_) => as_art(doc, art, None).unwrap_or_else(|| art.clone()), + _ => outlined_members(doc, art), + } +} + +/// The paths of [`project_leaves`] outside the art of masks. +fn project_paths(art: &mut Node, h: &vectorcraft_geom::Homography) { let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p); let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25)); let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f); @@ -687,58 +707,151 @@ fn hoist_copies(art: &mut Node) { /// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). /// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect /// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when -/// `owner` does not hold it. +/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of +/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the +/// type and live objects in it outlined and evaluated first ([`outlined_members`]). When a mask +/// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked +/// masks keeps its shape under that effect. fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { let mut whole = crate::Carry::default(); if fx.is_empty() { return whole; } - let mut leaves_safe = true; - for_each_leaf(art, &mut |leaf| { - if !leaves_safe { - return; - } - if let Some((_, rule)) = leaf_geometry(leaf) { - let has_stroke = leaf.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - if rule != FillRule::NonZero || has_stroke { - leaves_safe = false; - } - } - }); + let masked = has_linked_mask(art, false); + if masked { + each_linked_mask_mut(art, false, 0, &mut |m| *m = outlined_mask_art(doc, m)); + } + let mut masks_safe = true; + each_linked_mask(art, false, 0, &mut |_, m| masks_safe &= leaves_safe(m)); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - let style_safe = leaves_safe && !art_stroke; + let style_safe = leaves_safe(art) && masks_safe && !art_stroke; let listed = fx; let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; let mut used = art_cost(art); + each_linked_mask(art, false, 0, &mut |_, m| used = used.plus(art_cost(m))); let max = budget.at_least(used); - // Read before the effects run: a path's own copies don't make it art of several objects. + // Read before the effects run: a path's own copies don't make it art of several objects. The + // same holds for the art of each mask, listed by the object it masks. let copy_whole = copies_whole(art); + let mut wholes = HashMap::new(); + each_linked_mask(art, false, 0, &mut |id, m| { + wholes.entry(id).or_insert_with(|| copies_whole(m)); + }); let mut warped = WarpedImages::new(); for e in fx { let Some(bounds) = warped.bounds(art) else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); + let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc, wholes: &wholes }; + // Art with linked masks keeps its shape under an effect that a mask in it could not take + // as the art it masks did, so no mask is left apart from its art. + let before = masked.then(|| (art.clone(), used)); + let ran = step.run(art, max, &mut used, copy_whole, Some(&mut warped), false, 0); + if (ran.masks || ran.short) + && let Some((was, cost)) = before + { + *art = was; + used = cost; + continue; + } + if let Some(carry) = ran.whole { + whole.push(carry); + } + } + cut_warped_images(art, &warped); + whole +} + +/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its +/// merged params, the reference box and seed of the object's art, where a deferred live-effect +/// plug-in is listed, whether each Transform copy becomes an object, the document that the +/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of +/// each linked mask before the first effect, by the id of the object it masks. +struct Step<'a> { + e: &'a Effect, + params: &'a serde_json::Value, + bounds: Rect, + seed: u64, + effect: Option, + apart: bool, + doc: Option<&'a Document>, + wholes: &'a HashMap, +} + +/// What [`Step::run`] did to a piece of art. +struct Ran { + /// A path in the art kept its shape (past the budget). + kept: bool, + /// The linked opacity mask of an object in the art stayed as it was while the paths it masks + /// took the effect, or could have ([`Step::reshape_masks`]). + masks: bool, + /// A Transform effect made fewer copies of a path, image or symbol instance than it asks for + /// (past the budget or out of reach), where a linked mask could take a different number. + short: bool, + /// How what paints the art as a whole follows the effect, when it does. + whole: Option, +} + +impl Ran { + /// Nothing in the art kept its shape or made fewer copies, and every linked mask in it moved. + fn complete(&self) -> bool { + !self.kept && !self.masks && !self.short + } +} + +impl Step<'_> { + /// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to + /// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the + /// art, or of a mask's art, before the first effect. `warped` lists the images Warp effects + /// bend in the art ([`WarpedImages`]); a mask's art has none. With `own`, `art` is the art of a + /// mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is in. A + /// live-effect plug-in that paints its pieces leaves a mask's art, and art that holds objects + /// with linked masks, as they are and reports the masks as stayed: a mask can't take the + /// pieces as the art it masks does. So does a Warp effect that bends an image with a linked + /// mask. + #[allow(clippy::too_many_arguments)] + fn run( + &self, + art: &mut Node, + max: Budget, + used: &mut Budget, + copy_whole: bool, + mut warped: Option<&mut WarpedImages>, + own: bool, + depth: usize, + ) -> Ran { + let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed); + let stayed = Ran { kept: false, masks: true, short: false, whole: None }; if let Some(plugin) = painted_plugin(e) { - let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + if own || has_linked_mask(art, false) { + return stayed; + } + let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; let before = Budget::of(&path); let room = max.minus(used.minus(before)); - if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) { - used = used.minus(before).plus(made); + if let Some(made) = paint_leaf(leaf, plugin, params, bounds, room, self.doc, at) { + *used = used.minus(before).plus(made); } }); - continue; + return Ran { kept: false, masks: false, short: false, whole: None }; + } + // A Warp effect that bends images ([`mark_warped_images`]) bends an image's pixels, not the + // art of its linked mask. + let warp = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id).filter(|_| crate::warp::bends(&e.id, params)); + if warp.is_some() && has_masked_image(art) { + return stayed; } // Placed gradients follow a Transform effect, Free Distort and the Warp effects // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from // its bounds before the effect to its bounds after it. Under those and the other effects, // the placed gradients of type's outlines in `art` (`outline_text`) move and scale with // the outlines' bounds. - let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); + let follows = crate::Carry::follows(&e.id, params, bounds, seed); let boxes = follows && crate::Carry::by_bounds(&e.id); let mut outlines = vec![]; if !follows || boxes { @@ -754,30 +867,61 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // The images earlier Warps bend are measured by their pieces only when a group's // gradient needs the bounds. if !groups.is_empty() - && let Some(images) = warped.when_cut(art) + && let Some(images) = warped.as_deref().and_then(|w| w.when_cut(art)) { groups.clear(); group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups); } } // The ids of the paths that kept their shape (past the budget), and their places in the - // order [`for_each_leaf`] visits the paths. + // order [`for_each_leaf`] visits the paths; and the ids of the paths whose linked masks + // moved with them as they were transformed as objects. let mut kept_ids = HashSet::new(); let mut kept_leaves = HashSet::new(); let mut leaf_at = 0usize; + let mut moved_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { - let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); - let options = crate::distort::TransformOptions::of(¶ms); - (m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed)) + let (m, copies) = crate::distort::transform_map(bounds, params, seed); + let options = crate::distort::TransformOptions::of(params); + (m, copies, options, crate::distort::transform_affine(bounds, params, seed)) }); + // In a mask's art, images and symbol instances take only a Transform effect + // ([`transform_placed`]): a mask's art that holds one can't take another effect as the art + // it masks does. + if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) { + return Ran { kept: false, masks: true, short: false, whole: None }; + } // Art of several objects is copied whole, so the copies don't interleave member by member. + // The copies of its members carry their linked masks, moved with them. if let Some((m, copies, options, _)) = transform && copies > 0 && copy_whole { - used = copy_art(art, m, copies, copy_scaling(options), max); - continue; + if !own { + *used = copy_art(art, m, copies, copy_scaling(options), max); + return Ran { kept: false, masks: false, short: false, whole: None }; + } + // A mask's art: its copies keep to what the rest of the art leaves of `max`, and the + // linked mask of the art itself takes them too. That mask's art stays in `used`, as + // [`copy_art`] counts only the members. + let before = copy_cost(art); + let rest = used.minus(before).plus(art.extras().mask.as_deref().map_or(Budget { anchors: 0, lines: 0 }, |k| copy_cost(&k.art))); + let made = copy_art(art, m, copies, copy_scaling(options), max.minus(rest)); + *used = rest.plus(made); + let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies); + let none = HashSet::new(); + let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth); + return Ran { kept: false, masks, short, whole: None }; } + // A shortfall in the copies matters for the objects a linked mask above them covers, which + // the mask would take a different number of copies than, and anywhere in a mask's art. + let watch = transform.is_some_and(|(_, copies, options, _)| copies > 0 && options.objects) && (own || has_linked_mask(art, false)); + let mut covered = HashSet::new(); + if watch && !own { + masked_ids(art, false, &mut covered); + } + let counts = |id: NodeId| watch && (own || covered.contains(&id)); + let mut short = false; for_each_leaf(art, &mut |leaf| { let at = leaf_at; leaf_at += 1; @@ -790,36 +934,55 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // strokes; a gradient paints each copy by itself, not the one piece the copies // make together; and Expand Appearance (`apart`) makes each copy an object. // Fills and strokes that stack paint each copy below the one before (#5920, #6316). + // Each copy of a path with a linked mask carries the mask, moved with it. let separate = copies > 0 - && (apart + && (self.apart || leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient() - || paints_copies_apart(&leaf.appearance, rule)); + || paints_copies_apart(&leaf.appearance, rule) + || leaf.extras().mask.as_ref().is_some_and(|m| m.linked)); + // The copies of a path replace it by a group of them, the path included. + let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len); if options.reach_appearance() { - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); - used = used.minus(before).plus(made); - if !moved { + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None); + *used = used.minus(before).plus(made); + if moved { + // With Transform Objects off the path stays, and its mask takes the + // effect's stroke and pattern changes as its own art. + if options.objects { + moved_ids.insert(leaf.id); + } + short |= counts(leaf.id) && copied(leaf) <= copies; + } else { kept_ids.insert(leaf.id); } return; } if separate { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; - let made = copy_leaf(leaf, &path, m, copies, scaling, room); - used = used.minus(before).plus(made); + let made = copy_leaf(leaf, &path, m, copies, scaling, room, None); + *used = used.minus(before).plus(made); + moved_ids.insert(leaf.id); + short |= counts(leaf.id) && copied(leaf) <= copies; return; } } - let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); - let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { + let stage = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + let Some(out) = apply_one(&e.id, params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); kept_leaves.insert(at); return; }; - used = used.minus(before).plus(Budget::of(&out)); + // A Transform effect's copies of a path are subpaths of it, each with the path's anchors. + if counts(leaf.id) + && let Some((_, copies, _, _)) = transform + { + short |= out.anchor_count() < path.anchor_count().saturating_mul(copies.saturating_add(1)); + } + *used = used.minus(before).plus(Budget::of(&out)); if follows && !boxes { // A gradient fitted to the path fits the path's own bounds. - let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed); + let carry = crate::Carry::of(&e.id, params, bounds, path.bounds(), None, seed); if carry.moves(&leaf.appearance) { carry.apply(&mut leaf.appearance); } @@ -832,36 +995,230 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // Warp bends images too: each one's pixels follow the warp's map over the same box. The // images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image // whose clipping frame kept its shape (past the budget) keeps its pixels as they are. - if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id) - && crate::warp::bends(&e.id, ¶ms) + if let Some(style) = warp + && let Some(warped) = warped.as_deref_mut() { - let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, ¶ms)); + let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, params)); warped.reserve(art); - mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped); + mark_warped_images(art, &f, &kept_leaves, false, &mut 0, warped); } if let Some((m, copies, options, _)) = transform && options.objects { - transform_placed(art, m, copies); + // A mask's art that is one image or symbol instance is placed as the only member of a + // group of it. + let root = is_placed(art); + if root { + let id = art.id; + let one = std::mem::replace(art, Node::group(id, vec![])); + *art = Node::group(id, vec![Arc::new(one)]); + } + let placed = if watch { masked_placed(art, own) } else { 0 }; + transform_placed(art, m, copies, copy_scaling(options)); + short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1)); + if root && let Some([one]) = art.children().map(Vec::as_slice) { + *art = (**one).clone(); + } } if outlines.iter().any(Option::is_some) { rebase_type_outlines(art, &mut outlines.into_iter()); } + // Under Transform copies, a mask takes the copies as the paths it masks do; the copies of a + // path with a mask of its own each carry it ([`copy_leaf`]). + let masks = self.reshape_masks(art, own, &Skip { moved: &moved_ids, kept: &kept_ids }, max, used, depth); // What paints the art as a whole, and the own fills and strokes of the groups inside it, // follow their bounds under Free Distort and Warp (they stay when no path changed them). // Each stays when a path under it that its bounds measure kept its shape (past the budget). let mut after = vec![]; - let images = if groups.is_empty() { None } else { warped.when_cut(art) }; + let images = if groups.is_empty() { None } else { warped.as_deref().and_then(|w| w.when_cut(art)) }; let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1; if !groups.is_empty() { - carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); + carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, params, bounds, from, to, seed)); } - if follows && !held { - whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), warped.bounds(art), seed)); + let drawn = warped.as_deref().map_or_else(|| art.geometric_bounds(), |w| w.bounds(art)); + let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), drawn, seed)); + Ran { kept: !kept_ids.is_empty(), masks, short, whole } + } + + /// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path, + /// compound path, group and layer under it, with the art's reference box, as the paths the mask + /// covers take it: the mask moves and bends with what it masks. The mask of an object + /// transformed as an object moved with it already and is left as it is. A mask stays as it was + /// past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks, and for the reasons + /// [`Step::reshape_own_mask`] gives. Returns whether a mask stayed for one of those reasons. + fn reshape_masks(&self, n: &mut Node, own: bool, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return false; + } + let mut stayed = own && self.reshape_own_mask(n, skip, max, used, depth); + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + stayed |= self.reshape_masks(Arc::make_mut(c), true, skip, max, used, depth); + } + } + } + stayed + } + + /// Run the effect over the art of the linked opacity mask of `n` itself, when `n` is a path, + /// compound path, group or layer that `skip` does not list as moved. The mask stays as it was + /// when a path it masks kept its shape, and when its own art could not take the effect whole: a + /// path in it would keep its shape, its copies would stop short, or a mask inside it would stay + /// (no mask is left half moved). Returns whether the mask stayed for one of those reasons. + fn reshape_own_mask(&self, n: &mut Node, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !reshapes_mask_of(n) || skip.moved.contains(&n.id) { + return false; + } + let stays = holds_any(n, skip.kept); + let id = n.id; + let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false }; + let before = *used; + let mut art = (*m.art).clone(); + let whole = self.wholes.get(&id).copied().unwrap_or_else(|| copies_whole(&art)); + let ran = (!stays).then(|| self.run(&mut art, max, used, whole, None, true, depth + 1)); + match ran { + Some(ran) if ran.complete() => { + // What paints the mask's art as a whole follows the effect as the art does. + if let Some(carry) = ran.whole + && carry.moves(&art.appearance) + { + carry.apply(&mut art.appearance); + } + m.art = Arc::new(art); + false + } + _ => { + *used = before; + true + } } } - cut_warped_images(art, &warped); - whole +} + +/// The objects whose linked masks [`Step::reshape_masks`] leaves as they are: those transformed +/// as objects (`moved`), and those holding a path that kept its shape (`kept`). +struct Skip<'a> { + moved: &'a HashSet, + kept: &'a HashSet, +} + +/// Is `n` an object whose linked opacity mask a geometry effect on the art it is in reshapes with +/// it: a path, compound path, group or layer? An image or symbol instance moves its mask only +/// with the map a Transform effect places it by ([`transform_placed`]). +fn reshapes_mask_of(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Does `n` (with `own`) or a path, compound path, group or layer under it carry a linked opacity +/// mask that [`Step::reshape_masks`] reshapes? +fn has_linked_mask(n: &Node, own: bool) -> bool { + let mine = own && reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked); + mine || match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().any(|c| has_linked_mask(c, true)), + _ => false, + } +} + +/// Run `f` on the id of `n` (with `own`) and of each path, compound path, group and layer under it +/// that carries a linked opacity mask, with the art of that mask, and on the masks inside that +/// art, as [`Step::reshape_masks`] reaches them: `depth` masks deep already, up to +/// [`vectorcraft_doc::depth::MAX_DEPTH`]. +fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(NodeId, &Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked) + { + f(n.id, &m.art); + each_linked_mask(&m.art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind { + for c in children { + each_linked_mask(c, true, depth, f); + } + } +} + +/// [`each_linked_mask`] that changes the art of each mask: `f` runs on a mask's art before the +/// masks inside it are reached. +fn each_linked_mask_mut(n: &mut Node, own: bool, depth: usize, f: &mut dyn FnMut(&mut Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) + { + let art = Arc::make_mut(&mut m.art); + f(art); + each_linked_mask_mut(art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + each_linked_mask_mut(Arc::make_mut(c), true, depth, f); + } + } + } +} + +/// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself? +fn has_own_linked_mask(n: &Node) -> bool { + reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked) +} + +/// Add to `out` the ids of the objects under `n` that the linked opacity mask of an object above +/// them covers (every one of them with `under`). +fn masked_ids(n: &Node, under: bool, out: &mut HashSet) { + for c in n.children().into_iter().flatten() { + if under { + out.insert(c.id); + } + masked_ids(c, under || has_own_linked_mask(c), out); + } +} + +/// How many images and symbol instances under `n` the linked opacity mask of an object above them +/// covers (every one of them with `under`). +fn masked_placed(n: &Node, under: bool) -> usize { + n.children().into_iter().flatten().map(|c| usize::from(under && is_placed(c)) + masked_placed(c, under || has_own_linked_mask(c))).sum() +} + +/// Is `n`, or anything under it, one of `ids`? +fn holds_any(n: &Node, ids: &HashSet) -> bool { + if ids.is_empty() { + return false; + } + let mut any = false; + n.walk(&mut |c| any |= ids.contains(&c.id)); + any +} + +/// Do the paths under `n` that [`for_each_leaf`] visits all fill by the non-zero rule without a +/// stroke that paints? Effects then reshape their outlines alike in any order that the +/// optimization of [`crate::geometry::optimize_geometry_effects`] takes. +fn leaves_safe(n: &Node) -> bool { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => { + let has_stroke = n.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); + leaf_geometry(n).is_none_or(|(_, rule)| rule == FillRule::NonZero && !has_stroke) + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().all(|c| leaves_safe(c)), + _ => true, + } +} + +/// Is there an image with a linked opacity mask among the members of `n`, or among its groups' +/// and layers' members, which [`mark_warped_images`] walks? +fn has_masked_image(n: &Node) -> bool { + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return false }; + children.iter().any(|c| match c.kind { + NodeKind::Image(_) => c.extras().mask.as_ref().is_some_and(|m| m.linked), + _ => has_masked_image(c), + }) } /// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by @@ -888,8 +1245,9 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S /// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its /// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them /// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform -/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after, -/// and `false` when the path kept its shape because the moved path would pass `budget`. +/// Objects off the geometry stays and the options apply once. The effects `kept` names keep their +/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and +/// `false` when the path kept its shape because the moved path would pass `budget`. fn transform_leaf( leaf: &mut Node, path: &PathData, @@ -897,6 +1255,7 @@ fn transform_leaf( copies: usize, options: crate::distort::TransformOptions, budget: Budget, + kept: Kept, ) -> (Budget, bool) { let scaling = copy_scaling(options); if !options.objects { @@ -909,6 +1268,7 @@ fn transform_leaf( if options.patterns { vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); } + keep_listed(leaf, kept); return (Budget::of(path), true); } if copies == 0 { @@ -918,9 +1278,10 @@ fn transform_leaf( return (Budget::of(path), false); } leaf.transform(m, scaling); + keep_listed(leaf, kept); return (Budget::of(&moved), true); } - (copy_leaf(leaf, path, m, copies, scaling, budget), true) + (copy_leaf(leaf, path, m, copies, scaling, budget, kept), true) } /// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a @@ -1066,9 +1427,10 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: /// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass /// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's -/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original -/// included. -fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { +/// strokes would scale past [`MAX_STROKE_SCALE`]. The effects `kept` names keep their values on +/// each copy, before its cost is counted ([`keep_listed`]). Returns what the copies cost, the +/// original included. +fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget, kept: Kept) -> Budget { let mut made = Budget::of(path).plus(effects_cost(leaf, path)); let mut placements: Vec> = vec![Arc::new(leaf.clone())]; let mut xf = Affine::IDENTITY; @@ -1081,8 +1443,11 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling } let mut copy = leaf.clone(); copy.transform(xf, scaling); + keep_listed(&mut copy, kept); // Each copy's own geometry effects make a different amount, as the copy and its effects scale. - let cost = Budget::of(&moved).plus(effects_cost(©, &moved)); + let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); + // Each copy carries the art of its linked masks, moved with it. + each_linked_mask(©, true, 0, &mut |_, m| cost = cost.plus(art_cost(m))); if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } @@ -1225,9 +1590,12 @@ fn item_makes_piece(item: &AppearanceItem) -> bool { /// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after /// the geometry effects of that fill or stroke before its first such Transform effect, transformed /// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the -/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the -/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer -/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then +/// stroke's weight and dashes, the distances of the effects before the Transform effect and its +/// pattern follow the map, and the raster effects before it that follow it take their followed +/// values ([`follow_piece`]). The effects after it keep their values and apply to what it made +/// ([`keep_listed`]), and with Transform Objects off so do the effects before it. The reference box +/// is that geometry's bounds and the seed is the leaf's id, as the renderer draws a fill's or +/// stroke's own effects. The geometry effects after the Transform effect then /// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` @@ -1300,9 +1668,16 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // A Transform effect after this one that makes copies or moves at random holds the raster + // effects before this one back from every Transform effect ([`crate::raster::followed`]). + let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back); + // The effects before the Transform effect that it keeps, each raster effect following the + // geometry effects before it ([`crate::raster::without_geometry`]) unless held back. + let kept_before: Vec = + if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) }; if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut one = Appearance { items: vec![it.clone()], ..Default::default() }; if carry.moves(&one) { @@ -1324,7 +1699,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), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1332,16 +1707,22 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); - *it.effects_mut() = rest; + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest.clone(); let mut p = piece(vec![it]); if carry.moves(&p.appearance) { carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); + let after = kept_before.len(); + let keep = |k: usize| k >= after || !options.objects; + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep))); *used = used.plus(made); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + let from = crate::raster::held_back(before); + 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); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1358,27 +1739,45 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// 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 -/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with -/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. +/// the effect, at the effect's own distances. A Transform effect with Transform Patterns or Scale +/// Strokes & Effects on transforms each path as [`transform_leaf`] does, and leaves the values of +/// 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 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]), max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// 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 +/// 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>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { count = count.max(item.effects().len()); } }); + // The effects before a Transform effect among the piece's effects that makes copies or moves at + // 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 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); @@ -1395,8 +1794,12 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m 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 (made, _) = transform_leaf(leaf, &path, map, copies, options, room); + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep))); *used = used.minus(before).plus(made); + if moved { + follow_piece(leaf, was, from, &e); + } return; } let appearance = leaf.appearance.clone(); @@ -1413,7 +1816,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m carry.apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); + follow_piece(leaf, was, from, &e); }); + if holds { + held = held.max(j); + } } for_each_leaf(p, &mut |leaf| { for item in &mut leaf.appearance.items { @@ -1422,6 +1829,53 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m }); } +/// 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. +type Kept<'a> = Option<(&'a [Effect], &'a dyn Fn(usize) -> bool)>; + +/// Put back on the fills and strokes of each path under `p` the effect `kept`'s list holds at each +/// index it takes, in place of what a Transform effect in their own list scaled or moved with its +/// options ([`transform_leaf`]). The effects after the Transform effect apply to the art it made, +/// and with Transform Objects off the effects before it keep their values too. A piece's fills and +/// strokes share one list: a live-effect plug-in that paints what it returns gives each paint a copy +/// of the fill or stroke ([`vectorcraft_plugins::painted::items`]). +fn keep_listed(p: &mut Node, kept: Kept) { + let Some((was, keep)) = kept else { return }; + for_each_leaf(p, &mut |leaf| { + for item in &mut leaf.appearance.items { + for (k, (e, old)) in item.effects_mut().iter_mut().zip(was).enumerate() { + if keep(k) { + e.clone_from(old); + } + } + } + }); +} + +/// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow +/// Transform effect `t`, listed after them, once `t` has moved the leaf +/// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a +/// painted plug-in returns, [`vectorcraft_plugins::painted::items`]), with the same effects. `was` +/// holds those effects of the first one as they were before `t` was applied: each one that +/// follows takes the value [`crate::raster::followed`] gives it, in place of what Scale Strokes & +/// Effects made of it, and the others keep theirs. +fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { + let mut follow = was.to_vec(); + if !crate::raster::follow_one(&mut follow, t) { + return; + } + for item in &mut leaf.appearance.items { + for (k, (new, old)) in follow.iter().zip(was).enumerate() { + if new != old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new.clone(); + } + } + } +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { @@ -1622,17 +2076,20 @@ fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine { /// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as /// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more /// follow, each mapped once more than the last. Without copies the object itself takes the map, -/// as a path does. -fn transform_placed(n: &mut Node, m: Affine, copies: usize) { - transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS) +/// as a path does. The art of each one's linked opacity mask takes the map with `scaling`, as a +/// path's mask does. +fn transform_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling) { + let mut room = crate::distort::MAX_COPY_OBJECTS.saturating_sub(placed_count(n)); + copy_placed(n, m, copies, scaling, &mut room); } /// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art, /// the originals included: each copy adds the object once more, and a Transform after this one /// copies the lot again, so copies stop where the result would pass `max` (#2042). +#[cfg(test)] pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) { let mut room = max.saturating_sub(placed_count(n)); - copy_placed(n, m, copies, &mut room); + copy_placed(n, m, copies, vectorcraft_doc::Scaling::default(), &mut room); } /// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number). @@ -1864,11 +2321,11 @@ fn is_placed(n: &Node) -> bool { /// document order, each copy taking one from `room` (what the art can still grow by), until the /// budget is full. As with a path, the copies stop before one reaches past /// [`crate::distort::within_reach`]. -fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { +fn copy_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, room: &mut usize) { let Some(children) = n.children_mut() else { return }; for c in children.iter_mut() { if !is_placed(c) { - copy_placed(Arc::make_mut(c), m, copies, room); + copy_placed(Arc::make_mut(c), m, copies, scaling, room); continue; } let src = (**c).clone(); @@ -1887,6 +2344,12 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() { one.appearance.transform_gradients(xf); } + // So does the art of its linked opacity mask. + if xf != Affine::IDENTITY + && let Some(m) = one.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) + { + Arc::make_mut(&mut m.art).transform(xf, scaling); + } one }; if copies == 0 { diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index ec9563f05c..d82e4e8818 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; +use crate::solids::{ + Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, +}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -507,6 +509,60 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// The outline of every face [`revolve_art`] draws for `n`'s Revolve or Revolve (Classic) (its symbol +/// instances, blends and clipping groups already resolved), in document coordinates: with the +/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of +/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for +/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in +/// after a fill's or stroke's Revolve are looked up in `doc`. +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { + let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) + }; + if is_container(n) { + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && is_revolve(&e.id)) + .ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let mut shapes = members(n, "Revolve")?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; + let options = shared_options(&shapes, fx_options(fx))?; + let mut out = vec![]; + for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) { + out.extend(faces(&s.path, b, options)?); + } + return Ok(out); + } + let (path, _) = object_profile(n, "Revolve")?; + let ctx = GeomContext::of(n); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + if surface_item(&solid_paints(n)).is_none() { + return Err("the revolved object has no fill or stroke".into()); + } + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + return faces(&path, b, fx_options(fx)); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) + && let Some(fx) = item.effects().get(index) + { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let surface = faces(&p, b, fx_options(fx))?; + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); + } + } + Ok(out) +} + /// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let index = n @@ -558,17 +614,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); + remaining.retain(|e| !is_revolve(&e.id)); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining.retain(|e| !is_geometry(&e.id)); + remaining = crate::without_geometry(&remaining); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { @@ -835,8 +885,7 @@ fn group_art(n: &Node, expand: bool) -> Option { // One shadow for every member's surface, behind them all. let mut children = children; children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 538d2a5028..9e7578d6d7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -22,6 +22,9 @@ pub(crate) use source::{resolve, resolve_for}; pub(crate) mod shadow; +pub(crate) mod wireframe; +pub use wireframe::three_d_wireframe_in; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; @@ -978,17 +981,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + remaining = crate::without_geometry(&n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in &paints { @@ -1024,14 +1020,28 @@ fn group_solid_art(n: &Node) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let (shadow, surfaces) = art_for(n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); Some(art) } +/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the +/// art it projects. Each raster effect before it follows the Transform effects among those +/// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at +/// random ([`crate::raster::holds_back`]); the effects after it stay as they are. +pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec { + let (before, after) = effects.split_at(index.min(effects.len())); + let after = after.get(1..).unwrap_or_default(); + let kept: Vec = if after.iter().any(crate::raster::holds_back) { + before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() + } else { + crate::without_geometry(before) + }; + kept.into_iter().chain(after.iter().cloned()).collect() +} + /// One solid of a 3D object as a mesh, with the color it is painted. #[derive(Clone, Debug, PartialEq)] pub struct SolidMesh { diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs new file mode 100644 index 0000000000..f1dc324679 --- /dev/null +++ b/crates/effects/src/solids/wireframe.rs @@ -0,0 +1,276 @@ +//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that +//! can be edited on their own. +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; +use vectorcraft_three_d::Facet; + +use super::{ + INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, + region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, +}; +use crate::{GeomContext, is_geometry}; + +/// The most distinct edges one object's wireframe may have. +pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; + +/// The grid points are rounded to before edges are compared, in points: two faces that share an +/// edge give it once. +const GRID: f64 = 1.0 / 1024.0; + +const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)"; + +/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D +/// (Classic) forms), in document coordinates: the edges of every face the effect draws for the object as +/// it is turned and projected, each edge once. Edges that meet end to end are joined into one path; +/// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry +/// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a +/// group or layer, the edges of the solids of all its members. An error when `n` has no visible 3D +/// effect, when its solids can't be built, when the canvas draws the source art in their place, when +/// they draw no face, or past [`MAX_WIRE_EDGES`] edges. +pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, String> { + if !crate::has_revolve(n) && !has_solid(n) { + return Err(NO_EFFECT.into()); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + // The faces come from the effect whose art the canvas draws (Revolve first, as the canvas tries + // it first). When neither draws, past their limits, the canvas shows the source art and there + // is no surface to expand. + let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some(); + if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) { + // The builders' own error says why, when there is one. Revolve's art is cut for Revolve, + // as the canvas cuts it. + if crate::has_revolve(n) { + crate::revolve::revolve_face_paths(doc, &*resolve_for(doc, n, label, false)?)?; + } else { + solid_face_paths(doc, &*resolve(doc, n, label)?)?; + } + return Err(format!("{label} draws the source art for this object instead of its 3D surface")); + } + // Revolve's art is cut for Revolve whatever other 3D effect the object carries, as the canvas + // cuts it ([`crate::revolve_art_in`]). + let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? }; + let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; + let faces = after_geometry(doc, n, faces, revolve); + let wire = edges(&faces)?; + if wire.is_empty() { + return Err(format!("{label} draws no faces for this object")); + } + Ok(wire) +} + +/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them +/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude & +/// Bevel, Inflate or Rotate. +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, revolve: bool) -> Vec { + let fx = &n.appearance.effects; + let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) }; + let Some(index) = drawn else { return faces }; + reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) +} + +/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as +/// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect +/// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep +/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform, +/// and the document. +pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { + let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); + if after.is_empty() { + return faces; + } + let id = NodeId(ctx.seed); + let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +/// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect +/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer, +/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect +/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`. +fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { + if is_container(n) { + let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let mut shapes = members(n, label_of(&fx.id))?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + return facet_paths(fx, &shapes); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + let (path, rule) = object_profile(n, label)?; + let ctx = GeomContext::of(n); + let paints = solid_paints(n); + if let Some((index, fx)) = solid(&n.appearance.effects) { + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in &paints { + if let Some((index, fx)) = solid(item.effects()) { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); + } + } + Ok(out) +} + +/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and +/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's +/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, +/// as it is left out of the drawn art. +fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { + let label = label_of(&fx.id); + let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; + let p = crate::merged_params(&fx.id, &fx.params); + let mut out = vec![]; + if is_plane(&fx.id) { + for s in shapes { + for item in &s.items { + let (area, rule) = region(&s.path, s.rule, item); + out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path)); + } + } + return Ok(out); + } + let (parts, bounds) = solid_parts(shapes, b); + let built: Vec, String>> = if fx.id == INFLATE { + let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) + } else { + parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect() + }; + for ((item, ..), f) in parts.iter().zip(built) { + match f { + Ok(f) => out.extend(f.iter().map(Facet::path)), + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// A point on the [`GRID`]. +type Snap = (i64, i64); + +fn snap(p: Point) -> Snap { + ((p.x / GRID).round() as i64, (p.y / GRID).round() as i64) +} + +fn reversed(c: CubicBez) -> CubicBez { + CubicBez::new(c.p3, c.p2, c.p1, c.p0) +} + +/// The edges of `faces`, each once, joined end to end into paths ([`three_d_wireframe_in`]). +pub(crate) fn edges(faces: &[PathData]) -> Result, String> { + let mut seen: HashSet<[Snap; 4]> = HashSet::new(); + let mut segments: Vec = vec![]; + for sp in faces.iter().flat_map(|f| &f.subpaths) { + for i in 0..sp.segment_count() { + let mut c = sp.segment(i); + if sp.segment_is_line(i) { + c = CubicBez::new(c.p0, c.p0, c.p3, c.p3); + } + if [c.p0, c.p1, c.p2, c.p3].iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + continue; + } + let key = [c.p0, c.p1, c.p2, c.p3].map(snap); + // A segment that starts and ends on one grid point with its handles there too draws nothing. + if key.iter().all(|k| *k == key[0]) { + continue; + } + let back = [key[3], key[2], key[1], key[0]]; + if seen.contains(&back) || !seen.insert(key) { + continue; + } + if segments.len() >= MAX_WIRE_EDGES { + return Err(format!("the wireframe has more than {MAX_WIRE_EDGES} edges")); + } + segments.push(c); + } + } + Ok(chain(&segments)) +} + +/// `segments` joined end to end: each path starts at the first segment not yet used and takes in +/// the unused segments that meet its ends until none is left at either end, or it closes. +fn chain(segments: &[CubicBez]) -> Vec { + let mut at: HashMap> = HashMap::new(); + for (i, c) in segments.iter().enumerate() { + at.entry(snap(c.p0)).or_default().push(i); + if snap(c.p3) != snap(c.p0) { + at.entry(snap(c.p3)).or_default().push(i); + } + } + let mut used = vec![false; segments.len()]; + // The next unused segment at `point`, turned to leave it (`leave`) or to arrive at it. + let mut take = |used: &mut Vec, point: Snap, leave: bool| -> Option { + let list = at.get_mut(&point)?; + while let Some(&i) = list.last() { + if used.get(i).copied().unwrap_or(true) { + list.pop(); + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let c = *segments.get(i)?; + let starts = snap(c.p0) == point; + return Some(if starts == leave { c } else { reversed(c) }); + } + None + }; + let mut out = vec![]; + for (i, first) in segments.iter().enumerate() { + if used.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let mut run: VecDeque = VecDeque::from([*first]); + // A path of one anchor has no segment (`SubPath::segment_count`), so a run of one segment + // that comes back to its start stays open, with its two ends on one point. + let closes = |run: &VecDeque| run.len() > 1 && run.front().zip(run.back()).is_some_and(|(f, b)| snap(f.p0) == snap(b.p3)); + while !closes(&run) { + let Some(end) = run.back().map(|c| snap(c.p3)) else { break }; + let Some(c) = take(&mut used, end, true) else { break }; + run.push_back(c); + } + while !closes(&run) { + let Some(start) = run.front().map(|c| snap(c.p0)) else { break }; + let Some(c) = take(&mut used, start, false) else { break }; + run.push_front(c); + } + out.push(PathData::single(subpath(&run, closes(&run)))); + } + out +} + +/// `run`, segments that meet end to end, as one subpath; a closed one ends at its first anchor. +fn subpath(run: &VecDeque, closed: bool) -> SubPath { + let mut anchors: Vec = vec![]; + if let Some(first) = run.front() { + let h_in = if closed { run.back().map_or(first.p0, |b| b.p2) } else { first.p0 }; + anchors.push(Anchor::with_handles(first.p0, h_in, first.p1)); + } + for (k, c) in run.iter().enumerate() { + let h_out = run.get(k + 1).map_or(c.p3, |next| next.p1); + anchors.push(Anchor::with_handles(c.p3, c.p2, h_out)); + } + // A closed run's last anchor is its first, whose in handle already comes from the last segment. + if closed { + anchors.pop(); + } + SubPath::new(anchors, closed) +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs new file mode 100644 index 0000000000..5ea4423ab3 --- /dev/null +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -0,0 +1,235 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; + +use super::*; + +fn square(x: f64, y: f64, size: f64) -> PathData { + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + size, y)); + b.line_to((x + size, y + size)); + b.line_to((x, y + size)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// A filled 100 pt square without a stroke, with effect `id` and `params`. +fn solid_square(id: &str, params: Value) -> Node { + let mut n = Node::path(NodeId(1), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + n +} + +/// Every segment of `wire`, each as its two ends on the grid. +fn segments(wire: &[PathData]) -> Vec<(Snap, Snap)> { + wire.iter() + .flat_map(|p| &p.subpaths) + .flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))) + .map(|c| (snap(c.p0), snap(c.p3))) + .collect() +} + +fn bounds(paths: &[PathData]) -> Rect { + paths.iter().filter_map(PathData::bounds).reduce(|a, b| a.union(b)).unwrap() +} + +/// The paths of the faces `art` draws (every leaf of the art). +fn face_paths(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +#[test] +fn two_faces_that_share_an_edge_give_it_once() { + let wire = edges(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]).unwrap(); + let segs = segments(&wire); + assert_eq!(segs.len(), 7, "{segs:?}"); + for (i, a) in segs.iter().enumerate() { + for b in segs.iter().skip(i + 1) { + assert!(a != b && (a.1, a.0) != *b, "{a:?} twice"); + } + } +} + +#[test] +fn a_loop_of_edges_is_one_closed_path_and_a_curve_stays_a_curve() { + let wire = edges(&[square(0.0, 0.0, 10.0)]).unwrap(); + assert_eq!(wire.len(), 1); + assert!(wire[0].subpaths[0].closed); + assert_eq!(wire[0].subpaths[0].anchors.len(), 4); + + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0)); + b.close_path(); + let wire = edges(&[PathData::from_bezpath(&b)]).unwrap(); + let curved = wire.iter().flat_map(|p| &p.subpaths).any(|sp| (0..sp.segment_count()).any(|i| !sp.segment_is_line(i))); + assert!(curved, "{wire:?}"); +} + +/// A single curve that comes back to its start keeps its segment: it stays open, with two anchors on +/// one point, since a path of one anchor has no segment. +#[test] +fn a_single_curve_that_comes_back_to_its_start_keeps_its_segment() { + let p = Point::new(0.0, 0.0); + let anchors = vec![Anchor::with_handles(p, p, Point::new(0.0, 10.0)), Anchor::with_handles(p, Point::new(10.0, 10.0), p)]; + let face = PathData::single(SubPath::new(anchors, false)); + assert_eq!(face.subpaths[0].segment_count(), 1); + let wire = edges(&[face]).unwrap(); + assert_eq!(wire.len(), 1); + let sp = &wire[0].subpaths[0]; + assert_eq!(sp.segment_count(), 1, "{sp:?}"); + assert!(!sp.segment_is_line(0)); +} + +#[test] +fn edges_without_length_or_with_non_finite_points_are_left_out() { + let point = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], false)); + let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0)], false)); + assert!(edges(&[point, bad]).unwrap().is_empty()); + assert!(edges(&[]).unwrap().is_empty()); +} + +#[test] +fn a_box_turned_off_axis_shows_the_nine_edges_of_its_three_faces() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert_eq!(segments(&wire).len(), 9, "{wire:?}"); + // The wireframe covers what the effect draws, in the same place. + let art = super::super::solid_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } +} + +#[test] +fn the_classic_wireframe_surface_expands_to_all_twelve_edges() { + let n = solid_square(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})); + assert_eq!(segments(&three_d_wireframe_in(None, &n).unwrap()).len(), 12); +} + +#[test] +fn revolve_inflate_and_rotate_expand_too() { + for id in [crate::REVOLVE, super::super::INFLATE, ROTATE] { + let wire = three_d_wireframe_in(None, &solid_square(id, json!({}))).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(!wire.is_empty(), "{id}"); + assert!(wire.iter().flat_map(|p| &p.subpaths).flat_map(|sp| &sp.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite()), "{id}"); + } + // Rotate is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// Revolve (Classic) and Rotate (Classic) expand to the edges of the faces they draw, in the same +/// place. +#[test] +fn revolve_classic_and_rotate_classic_expand_too() { + let n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert!(!wire.is_empty()); + let art = crate::revolve_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } + // On a fill, with a geometry effect after it. + let mut n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap()); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{plain:?} {moved:?}"); + // Rotate (Classic) is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(super::super::CLASSIC_ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// The geometry effects after a 3D effect reshape each face on its own, as they reshape each face +/// of the drawn surface: two faces that touch stay two outlines. +#[test] +fn later_geometry_effects_reshape_each_face_on_its_own() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()]; + let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n)); + assert_eq!(out.len(), 2, "{out:?}"); + let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap()); + assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}"); + assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}"); +} + +/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. +#[test] +fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { + for id in [super::super::EXTRUDE, crate::REVOLVE] { + let mut n = solid_square(id, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"))); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{id}: {plain:?} {moved:?}"); + } +} + +/// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only +/// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again. +#[test] +fn a_geometry_effect_before_the_drawn_3d_effect_is_not_applied_again() { + let mut n = solid_square(crate::REVOLVE, json!({})); + n.appearance.effects.insert(0, crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + let alone = bounds(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.insert(0, crate::new_effect(super::super::EXTRUDE, &json!({})).unwrap()); + let both = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((both.x0 - alone.x0).abs() < 1e-6 && (both.x1 - alone.x1).abs() < 1e-6, "{alone:?} {both:?}"); +} + +/// A clipping group with Revolve and Rotate draws Revolve, which cuts the art's fills as one piece +/// in the top fill's place. Its wireframe is cut the same way, so art with more fills than a 3D +/// effect takes members still expands, to the edges of Revolve alone. +#[test] +fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { + let mut art = Node::path(NodeId(3), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = art.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).cloned().unwrap(); + art.appearance.items = vec![fill; super::super::MAX_MEMBERS + 4]; + let mut clip = Node::path(NodeId(2), square(100.0, 100.0, 50.0), Appearance::default()); + if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut n = Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &json!({})).unwrap()); + let alone = segments(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap()); + assert!(crate::revolve_art_in(None, &n).is_some(), "the canvas draws Revolve"); + let both = segments(&three_d_wireframe_in(None, &n).unwrap()); + assert_eq!(both, alone); +} + +#[test] +fn an_object_without_a_visible_3d_effect_is_an_error() { + let mut n = solid_square(super::super::EXTRUDE, json!({})); + n.appearance.effects[0].visible = false; + assert!(three_d_wireframe_in(None, &n).unwrap_err().contains("no 3D effect")); + n.appearance.effects.clear(); + assert!(three_d_wireframe_in(None, &n).is_err()); +} diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index acb980204c..d188bcafd6 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -4,7 +4,8 @@ //! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by //! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset -//! past the end point. Heads added after the first ten are drawn in local units ([`local`]). +//! past the end point. Heads added after the first ten are drawn in local units ([`local`]), as are +//! custom heads, whose stroke ends halfway along their length. use std::borrow::Cow; @@ -37,8 +38,14 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone()); let cap = cap_extent(st.cap, st.width); let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true)); - let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap))); - let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap))); + let start = st.start_arrow.clone().zip(first).map(|(k, r)| { + let i = inset(&k, hw0, cap); + (k, r, i) + }); + let end = st.end_arrow.clone().zip(last).map(|(k, r)| { + let i = inset(&k, hw1, cap); + (k, r, i) + }); if start.is_none() && end.is_none() { return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; } @@ -56,7 +63,8 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> } else { (end_pt + outward * inset, outward) }; - heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset }); + let outline = shape(&kind, tip, dir, hw); + heads.push(Arrow { kind, outline, tip, dir, inset }); }; if let Some((k, r, i)) = start { place(k, r, true, i, hw0); @@ -181,12 +189,12 @@ pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) { return (0.0, 0.0); } let cap = cap_extent(st.cap, st.width); - let inset_of = |kind: Option, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); - (inset_of(st.start_arrow, false), inset_of(st.end_arrow, true)) + let inset_of = |kind: &Option, end: bool| kind.as_ref().map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); + (inset_of(&st.start_arrow, false), inset_of(&st.end_arrow, true)) } /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. -fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { +fn inset(kind: &Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); // Solid heads: halfway in, far enough that the cap stays behind the tip. let solid = |back: f64| (back / 2.0).max(cap); @@ -199,6 +207,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l), // The inner corner of the chevron: arm / sin(half angle). Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap), + Arrowhead::Custom(c) => (c.stop() * hw).max(cap), _ => match local_stop(kind) { Stop::At(u) => (u * hw).max(cap), Stop::Wall(back) => hollow(back * hw), @@ -207,7 +216,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { } /// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`. -fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { +fn shape(kind: &Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { let n = Vec2::new(-dir.y, dir.x); let l = len(hw); let half = l / 2.0; @@ -265,7 +274,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; const LOCAL_TOL: f64 = 1e-3; /// Where the stroke ends under a head drawn in local units ([`local`]), in those units. -fn local_stop(kind: Arrowhead) -> Stop { +fn local_stop(kind: &Arrowhead) -> Stop { match kind { Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), Arrowhead::HexagonOpen => Stop::Wall(HEX), @@ -288,8 +297,8 @@ fn local_stop(kind: Arrowhead) -> Stop { /// The outline of a head added after the first ten, in local units: x from the tip (0) back /// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2, /// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape -/// is generated here. -fn local(kind: Arrowhead) -> BezPath { +/// is generated here, and a custom head's outline is its own. +fn local(kind: &Arrowhead) -> BezPath { let mut p = BezPath::new(); let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>()); let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>(), ring(1.0)); @@ -385,6 +394,7 @@ fn local(kind: Arrowhead) -> BezPath { let t = FRAC_1_SQRT_2; poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); } + Arrowhead::Custom(c) => p = c.path.to_bezpath(), // The first ten are drawn by `shape`. Arrowhead::Triangle | Arrowhead::TriangleOpen diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 400f3b58b9..f174069c4e 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -134,6 +134,10 @@ impl Plan { fn fitted(mut segs: Vec, mut lens: Vec, closed: bool, period: f64) -> Self { let n = segs.len(); + // A subpath with no segments (a lone moveto) has no runs to fit. + if n == 0 { + return Self::exact(segs, lens, closed, period); + } // Corners: where segments with length meet at a kink (zero-length ones have no direction). let live: Vec = (0..n).filter(|&i| lens[i] > EPS).collect(); let mut corners: Vec = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect(); diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 4bddf80b0a..ffd128b6dd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -32,6 +32,12 @@ const SHARP: f64 = 1.25; const HAIRPIN: f64 = 4.0; /// Rows of a mesh across the stroke (more where the gradient has stops). const MESH_ROWS: usize = 16; +/// The most stops of the gradient [`gradient_meshes`] puts rows or columns at +/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out). +pub(crate) const MAX_MESH_STOPS: usize = 1024; +/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more: +/// a subpath with more ribs than leave room for a row at each stop becomes several meshes. +pub(crate) const MAX_MESH_POINTS: usize = 1 << 20; /// The most of a subpath one column of a mesh along the stroke spans. const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. @@ -367,9 +373,12 @@ pub struct Slice { } impl Slice { - /// The slice's paint: its piece of `g` as a linear gradient placed from `from` to `to`. - pub fn paint(&self, g: &Gradient) -> Paint { - let gradient = g.span(self.span.0 as f32, self.span.1 as f32); + /// The slice's paint: its piece of `unrolled` as a linear gradient placed from `from` to `to`. + /// `unrolled` is the stroke's gradient as [`Gradient::unrolled`] gives it (unrolled once for + /// all the slices), so one that repeats or reflects runs its stops that many times along (or + /// across) the stroke. + pub fn paint(&self, unrolled: &Gradient) -> Paint { + let gradient = unrolled.span(self.span.0 as f32, self.span.1 as f32); let end = if self.from.distance(self.to) > 1e-9 { self.to } else { self.from + Vec2::new(1.0, 0.0) }; let geom = GradientGeom { start: self.from, end, aspect: 1.0, focal: None }; Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle: geom.angle_deg(), ..GradientPaint::new(gradient) })) @@ -471,40 +480,72 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines: _ => vectorcraft_pathops::stroke_region(outlines).to_bezpath(), }; let grow = if g.stops.iter().all(|s| s.opacity >= 1.0) { SLICE_GROW } else { 0.0 }; + let unrolled = g.unrolled(); let slices = gradient_slices(bp, rule, st, SLICE_TOL, SLICE_MARGIN, grow) .into_iter() .map(|s| { - let paint = s.paint(g); + let paint = s.paint(&unrolled); (s.shape, paint) }) .collect(); Some(WrittenSlices { clip, slices }) } +/// How far before and after a sharp step between two colors (two stops at one offset, as a +/// gradient that repeats has where one run meets the next) [`mesh_marks`] puts a mesh row or +/// column, as a fraction of the gradient's length. +const STEP_GAP: f64 = 1e-4; + +/// Where along gradient `g` (0 to 1) Outline Stroke's mesh puts a row or column for its stops +/// ([`Gradient::expanded_stops`]): at each stop, and for two stops at one offset (a sharp step, +/// or where a reflecting gradient turns back) also [`STEP_GAP`] before and after it, so the mesh +/// takes the color on each side. +fn mesh_marks(g: &Gradient) -> Vec { + let offsets: Vec = g.expanded_stops().iter().map(|s| f64::from(s.0)).collect(); + let shared = |i: usize| { + let t = offsets.get(i).copied().unwrap_or(0.0); + let near = |j: Option| j.and_then(|j| offsets.get(j)).is_some_and(|&u| (u - t).abs() < 1e-6); + near(i.checked_sub(1)) || near(i.checked_add(1)) + }; + let mut out = Vec::with_capacity(offsets.len() + 2); + for (i, &t) in offsets.iter().enumerate() { + out.push(t); + if shared(i) { + out.extend([(t - STEP_GAP).max(0.0), (t + STEP_GAP).min(1.0)]); + } + } + out +} + /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the -/// stroke's outline), coloured as [`gradient_slices`] paint it. +/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to +/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is +/// split into meshes of fewer ribs ([`rib_pieces`]). pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { + let fitted = g.fitted(MAX_MESH_STOPS); let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; + let marks: Vec = if across { vec![] } else { mesh_marks(&fitted) }; let max_dt = match (across, st.profile.is_some()) { (false, _) => MESH_STEP, (true, true) => PROFILE_STEP, (true, false) => 1.0, }; // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. - let mut us: Vec = if across { - (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(g.expanded_stops().iter().map(|s| s.0 as f64)).collect() - } else { - vec![0.5] - }; + let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let us = &us; + let g = &*fitted; + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + // A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than + // that leaves room for is split into meshes of fewer ribs. + let per_mesh = MAX_MESH_POINTS / (us.len() + 2); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) .into_iter() .filter(|ribs| ribs.len() > 1) + .flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec())) .map(|ribs| { let (rows, cols) = (us.len() + 1, ribs.len() - 1); // Each level across: a point on each rib and the gradient position there. @@ -541,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi }) .collect() } + +/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip: +/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before, +/// so the meshes meet without a gap. +pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec> { + let per_mesh = per_mesh.max(2); + (0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect() +} diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index fc3f2eee48..1c79fd4805 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -280,7 +280,7 @@ fn no_heads_borrows_the_path_and_closed_paths_get_none() { #[test] fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() { for kind in Arrowhead::ALL { - let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt); + let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let [h] = &p.heads[..] else { panic!("one head") }; @@ -335,7 +335,7 @@ fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() { #[test] fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] { - let st = arrows(kind, ArrowAlign::Tip, cap); + let st = arrows(kind.clone(), ArrowAlign::Tip, cap); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let h = &p.heads[0]; @@ -361,7 +361,7 @@ fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { fn round_caps_never_poke_past_the_tip() { for kind in Arrowhead::ALL { for scale in [10.0, 100.0, 300.0] { - let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round); + let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round); st.arrow_scale = (scale, scale); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); @@ -403,8 +403,8 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() { for pat in patterns { for off in [0.0, -7.0, 1e12, f64::INFINITY] { let st = stroke(2.0, |s| { - s.start_arrow = Some(kind); - s.end_arrow = Some(kind); + s.start_arrow = Some(kind.clone()); + s.end_arrow = Some(kind.clone()); s.arrow_align = align; s.cap = cap; s.dash = Some(dashed(pat, off)); diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 4328048991..a32a70245b 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -50,7 +50,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0)); assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself"); for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); let b = h.outline.bounding_box(); let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width()); @@ -73,7 +73,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { #[test] fn the_stroke_ends_inside_every_head() { for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); // Just in front of where the line stops, on its centre line. let end = h.tip - h.dir * (h.inset - 0.1); assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset); @@ -112,3 +112,30 @@ fn half_arrows_barb_on_their_own_side() { assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5); assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0); } + +#[test] +fn custom_heads_draw_their_own_art_at_the_tip() { + use vectorcraft_doc::CustomArrowhead; + use vectorcraft_geom::{PathData, SubPath}; + // From the tip back 3 units, 2 to the right of the line and 1 to the left. + let wedge = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + let kind = Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), wedge))); + let h = head(kind.clone()); + // Weight 2: drawn back to x 94, right of +x is +y on a y-down page. + let b = h.outline.bounding_box(); + assert!((b.x0 - 94.0).abs() < 1e-9 && (b.x1 - 100.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.y1 - 4.0).abs() < 1e-9, "{b:?}"); + assert_eq!(h.outline.winding(Point::new(95.0, 1.0)), h.outline.winding(Point::new(95.0, 0.5))); + assert_ne!(h.outline.winding(Point::new(95.0, 1.0)), 0); + // The line stops halfway along the head. + assert!((h.inset - 3.0).abs() < 1e-9, "inset {}", h.inset); + // Extended, the tip sits the inset past the end point, and scaling scales the art. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.arrow_scale) = (Some(kind), (200.0, 100.0)); + let mut l = BezPath::new(); + l.move_to((0.0, 0.0)); + l.line_to((100.0, 0.0)); + let p = stroke_pieces(&l, &st); + let [s] = &p.heads[..] else { panic!("one head") }; + assert!((s.tip.x + 6.0).abs() < 1e-9 && s.dir.x < 0.0, "tip {:?}", s.tip); + assert!((s.outline.bounding_box().width() - 12.0).abs() < 1e-9); +} diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b389cab853..cc69606aee 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -37,6 +37,20 @@ fn near(a: f64, b: f64, tol: f64) -> bool { // ---------------------------------------------------------------- dashes fitted to corners +/// A subpath with no segments (a lone moveto, as an HP-GL wedge of almost no radius gives) is +/// dashed fitted to corners without a panic, and the other subpaths keep their dashes. +#[test] +fn fitted_dashes_take_a_subpath_with_no_segments() { + let mut b = BezPath::new(); + b.move_to((5.0, 5.0)); + b.extend(line(100.0)); + let d = dash(&b, &fitted(&[10.0, 5.0], 0.0)).expect("dashed"); + assert!(moves(&d.path) > 1 && length(&d.path) > 0.0, "{:?}", d.path); + let mut lone = BezPath::new(); + lone.move_to((0.0, 0.0)); + assert!(dash(&lone, &fitted(&[10.0, 5.0], 0.0)).is_some_and(|d| d.path.elements().is_empty())); +} + #[test] fn every_corner_of_a_rectangle_falls_mid_dash() { // 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3 diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index ea966ea076..784c84d1db 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -286,6 +286,94 @@ fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { assert_covered(&hairpin, &st); } +/// A gradient along or across a stroke that repeats or reflects runs its stops that many times +/// along (or across) it: a slice's paint, and Outline Stroke's meshes, give each point the color +/// the spread gives its place. +#[test] +fn a_spread_gradient_runs_its_stops_along_the_stroke_that_many_times() { + use vectorcraft_color::GradientSpread; + let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false); + for (mode, spread) in [StrokeGradientMode::Along, StrokeGradientMode::Across] + .into_iter() + .flat_map(|m| [GradientSpread::Reflect, GradientSpread::Repeat].map(|s| (m, s))) + { + let mut st = stroke(10.0, mode); + let g = Gradient { spread, repeats: 3, ..Gradient::default() }; + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + for s in slices(&bp, &st) { + let Paint::Gradient(p) = s.paint(&g.unrolled()) else { panic!("a gradient slice") }; + let geom = p.geom.unwrap(); + for u in [0.1, 0.5, 0.9] { + let at = s.from + (s.to - s.from) * u; + let t = s.span.0 + (s.span.1 - s.span.0) * u; + // Where two runs meet, Repeat's color is the next run's start or the last one's end. + if (t * 3.0 - (t * 3.0).round()).abs() < 1e-9 { + continue; + } + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = p.gradient.sample(geom.param_at(vectorcraft_color::GradientKind::Linear, at) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-3), "{mode:?} at {t}: {want:?} vs {got:?}"); + } + } + // Outline Stroke: the mesh comes back to white where each run starts again, and reaches + // black where each run ends, on both sides of a sharp step between runs. + let count = |g: &Gradient, hex: &str| { + gradient_meshes(&bp, FillRule::NonZero, &st, g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == hex).count() + }; + for hex in ["#ffffff", "#000000"] { + let (spread, pad) = (count(&g, hex), count(&Gradient::default(), hex)); + assert!(spread > pad, "{mode:?} {:?} {hex}: {spread} vs {pad}", g.spread); + } + } +} + +/// Where a reflecting gradient turns back past a narrow last stop, Outline Stroke's mesh keeps that +/// stop's color. +#[test] +fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { + use vectorcraft_color::GradientSpread; + let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false); + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.995, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let mut st = stroke(10.0, mode); + let g = Gradient { spread: GradientSpread::Reflect, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops.clone()) }; + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let whites = + gradient_meshes(&bp, FillRule::NonZero, &st, &g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == "#ffffff").count(); + // The gradient turns back at white 50 times. + assert!(whites >= 50, "{mode:?}: {whites}"); + } +} + +/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100 +/// times: each mesh holds at most so many points, and every run still starts on its first color. +#[test] +fn a_mesh_across_many_ribs_and_stops_stays_bounded() { + use vectorcraft_color::GradientSpread; + let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect(); + let bp = poly(&pts, false); + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) }; + let mut st = stroke(4.0, StrokeGradientMode::Across); + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g); + assert!(!meshes.is_empty()); + for m in &meshes { + assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len()); + // A row at the start of each of the 100 runs, on every rib, is white. + let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count(); + assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len()); + } + // A strip of more ribs than one mesh has room for is split into meshes that share their end + // ribs, every rib in one of them. + use super::gradient::rib_pieces; + assert_eq!(rib_pieces(5, 3), [0..3, 2..5]); + assert_eq!(rib_pieces(4, 3), [0..3, 2..4]); + let one = rib_pieces(2, 1000); + assert_eq!((one.len(), one.first()), (1, Some(&(0..2)))); + assert_eq!(rib_pieces(3, 0), [0..2, 1..3]); +} + /// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along /// the path, however many points lie that far: not as far as it is wide anywhere when more than /// 256 do. diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..5c66c9fe9d 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -78,6 +78,35 @@ fn a_butt_capped_open_path_is_measured_where_it_paints() { } } +#[test] +fn beveled_corners_are_measured_where_they_paint() { + let mut tip = BezPath::new(); + tip.move_to((0.0, 100.0)); + tip.line_to((20.0, 0.0)); + tip.line_to((40.0, 100.0)); + tip.close_path(); + let mut chevron = BezPath::new(); + chevron.move_to((0.0, 100.0)); + chevron.line_to((20.0, 0.0)); + chevron.line_to((40.0, 100.0)); + // A rectangle with no width turns back at its top and bottom. + let flat = vectorcraft_geom::shapes::rectangle(Rect::new(3.0, 100.0, 3.0, 150.0)).to_bezpath(); + let ps = paths(); + for (name, bp) in [("tip", &tip), ("chevron", &chevron), ("zigzag", &ps[1]), ("triangle", &ps[2]), ("flat", &flat)] { + for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] { + for (join, limit) in [(LineJoin::Bevel, 10.0), (LineJoin::Miter, 1.0), (LineJoin::Miter, 2.0), (LineJoin::Miter, 10.0)] { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 20.0); + (st.cap, st.join, st.miter_limit) = (cap, join, limit); + let painted = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, &st).to_bezpath().bounding_box(); + let bounds = st.bounds_on(bp, bp.bounding_box()); + for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] { + assert!((got - want).abs() < 0.05, "{name} {cap:?} {join:?} {limit}: measured {bounds:?}, paints {painted:?}"); + } + } + } + } +} + #[test] fn bounds_and_clicks_cover_what_every_stroke_paints() { let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; @@ -99,14 +128,27 @@ fn bounds_and_clicks_cover_what_every_stroke_paints() { } } +/// Custom heads: one wholly behind its tip, and a long one reaching as far in front of its tip as +/// behind it (its tip sits halfway along it past the end point with Extend). +fn custom_heads() -> Vec { + use vectorcraft_geom::{Point as P, SubPath}; + let head = |name: &str, pts: &[(f64, f64)]| { + let pts: Vec

= pts.iter().map(|&(x, y)| P::new(x, y)).collect(); + let path = PathData::single(SubPath::polyline(&pts, true)); + Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(name.into(), path))) + }; + vec![head("Wedge", &[(0.0, 0.0), (3.0, 2.0), (3.0, -1.0)]), head("Spindle", &[(-10.0, 0.0), (0.0, 2.0), (10.0, 0.0), (0.0, -2.0)])] +} + #[test] fn bounds_and_clicks_cover_every_arrowhead() { let bp = &paths()[0]; - for kind in Arrowhead::ALL { + for kind in Arrowhead::ALL.into_iter().chain(custom_heads()) { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); - (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap); + (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = + (Some(kind.clone()), Some(kind.clone()), align, (scale, scale), cap); check(bp, &st, &format!("{kind:?} {align:?} {scale}%")); } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index e63b38a462..88300586b3 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod rasterfollow; mod scale; mod transformfx; @@ -1251,6 +1252,96 @@ fn roughen_smooth_makes_no_cusps() { } } +#[test] +fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() { + use vectorcraft_geom::AnchorKind; + use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let curve = PathData::single(SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)), + vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b), + ], + false, + )); + let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0)); + // Two subpaths with lines and curves: a closed one whose last segment is a line, and an open + // one that ends with a line. + let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0)); + let mixed = PathData::new(vec![ + SubPath::new( + vec![ + vectorcraft_geom::Anchor::corner(a), + vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)), + vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c), + ], + true, + ), + SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)), + vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e), + vectorcraft_geom::Anchor::corner(f), + ], + false, + ), + ]); + for path in [square(), ellipse, curve, mixed] { + let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect(); + let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt(); + for params in + [json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})] + { + let out = run("distort.roughen", params.clone(), &path); + assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added"); + assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}"); + for (sp, got) in path.subpaths.iter().zip(&out.subpaths) { + assert_eq!(got.closed, sp.closed); + // Walk the result one original segment at a time: its pieces end on the segment's end anchor. + let mut at = 0; + for i in 0..sp.segment_count() { + let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]); + let start = got.anchors[at]; + let was = sp.anchors[i]; + assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + let mut pieces = vec![]; + loop { + let piece = got.segment(at); + for j in 0..=8 { + let q = piece.eval(j as f64 / 8.0); + assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q)); + } + pieces.push(piece.arclen(1e-6)); + at += 1; + if piece.p3.distance(end.p) < 1e-9 { + break; + } + // A point the effect added: a corner without handles on a line, a smooth + // point whose handles follow the curve. + let added = got.anchors[at]; + if sp.segment_is_line(i) { + assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}"); + } else { + assert_eq!(added.kind, AnchorKind::Smooth, "{params}"); + let (hi, ho) = (added.h_in - added.p, added.h_out - added.p); + assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}"); + } + } + assert!(pieces.len() > 1, "{params}: segment {i} is split"); + assert!(close(pieces.iter().sum::(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}"); + let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l))); + assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}"); + } + assert_eq!(at, got.segment_count(), "{params}"); + if !sp.closed { + let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + } + } + } + } +} + #[test] fn zigzag_ridge_count_and_amplitude() { let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square()); diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs new file mode 100644 index 0000000000..c674cf5041 --- /dev/null +++ b/crates/effects/src/tests/rasterfollow.rs @@ -0,0 +1,421 @@ +//! Raster effects listed before a Transform effect follow it: the Transform effect transforms +//! what they paint. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::{RasterFx, bake_document, expand_outlined, raster_effects}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn shadow(x: f64, y: f64, blur: f64) -> Effect { + fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur})) +} + +fn transform(params: Value) -> Effect { + fx("distort.transform", params) +} + +/// The offset and blur of the first Drop Shadow `effects` draw. +fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) { + match raster_effects(effects).first() { + Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur), + other => panic!("expected a drop shadow, got {other:?}"), + } +} + +fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9 +} + +/// A 50 pt red square with `effects`. +fn square(id: u64, effects: Vec) -> Node { + let mut n = Node::path( + NodeId(id), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.appearance.effects = effects; + n +} + +#[test] +fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() { + for strokes in [false, true] { + let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))]; + assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects)); + } +} + +#[test] +fn a_drop_shadow_after_a_transform_keeps_its_offset() { + let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +#[test] +fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() { + let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))]; + assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven)); + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))]; + let (dx, dy, blur) = drawn_shadow(&turned); + assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}"); + let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))]; + assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected)); +} + +#[test] +fn transforms_after_the_shadow_apply_in_order() { + let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))]; + let (dx, dy, _) = drawn_shadow(&effects); + assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}"); + // Only the Transform after a shadow moves it. + let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)]; + let drawn = raster_effects(&between); + let offsets: Vec<(f64, f64)> = drawn + .iter() + .filter_map(|r| match r { + RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)), + _ => None, + }) + .collect(); + assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]); +} + +#[test] +fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() { + for params in [ + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "copies": 1}), + json!({"scaleH": 200, "scaleV": 200, "random": true}), + ] { + let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects)); + } + let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200})); + hidden.visible = false; + let effects = [shadow(10.0, 10.0, 4.0), hidden]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would +/// have before, once it is shown. +#[test] +fn a_hidden_shadow_follows_the_transform_it_loses() { + let mut hidden = shadow(10.0, 10.0, 4.0); + hidden.visible = false; + let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]); + assert_eq!(kept.len(), 1); + let mut shown = kept[0].clone(); + shown.visible = true; + assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); +} + +/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +#[test] +fn rasterize_before_a_transform_keeps_its_options() { + let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); + let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::raster::followed(&effects)[0], rasterize); + assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); +} + +/// An SVG filter keeps its options: it paints in its own user space. +#[test] +fn an_svg_filter_before_a_transform_keeps_its_options() { + let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]})); + let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))]; + assert_eq!(crate::raster::followed(&effects)[0], filter); + assert_eq!(crate::raster::without_geometry(&effects), vec![filter]); +} + +/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects +/// and from each fill's and stroke's. +fn shadows_in(n: &Node) -> Vec<(f64, f64)> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { dx, dy, .. } = r { + out.push((dx, dy)); + } + } + } + }); + out +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) +} + +/// A square whose fill carries `effects` of its own. +fn fill_square(id: u64, effects: Vec) -> Node { + let mut n = square(id, vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = effects; + } + n +} + +/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are +/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option +/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the +/// Transform effect a second time. +#[test] +fn a_fills_shadow_follows_the_fills_own_transform() { + let cases = [ + (json!({"rotate": 90}), (0.0, -10.0)), + (json!({"rotate": 90, "strokes": true}), (0.0, -10.0)), + (json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)), + (json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)), + (json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)), + ]; + for (params, want) in cases { + let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + // Drawn and expanded through its art when an option makes some. + if crate::has_transform_art(&n) { + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art)); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded)); + } else { + assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n)); + } + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = baked.node(NodeId(11)).unwrap(); + assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b)); + } +} + +/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the +/// shadow between them, which the first one leaves as it is; a Transform effect that moves at random +/// holds the shadow before it back from the Transform effects after it. +#[test] +fn a_fills_later_transform_turns_the_shadow_after_its_first() { + let n = fill_square( + 12, + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))], + ); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, -10.0)]), "{:?}", shadows_in(&art)); + let held = fill_square( + 13, + vec![ + shadow(10.0, 0.0, 0.0), + transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})), + transform(json!({"rotate": 90})), + ], + ); + let art = crate::reshape(None, &held, None).unwrap(); + let drawn = shadows_in(&art); + assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}"); +} + +/// A fill's own Transform effect with Transform Patterns or Scale Strokes & Effects on leaves the +/// fill's raster effects after it as they are: they paint around the transformed art, its copies +/// included. With Transform Objects off it leaves the raster effects before it as they are too. +/// Drawn, expanded and baked alike. +#[test] +fn a_fills_own_transform_leaves_the_shadow_after_it() { + let cases = [ + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 120, "copies": 1, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}))], + // A later Transform effect with an option leaves the shadow after it as well. + vec![ + transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})), + transform(json!({"scaleH": 150, "scaleV": 150, "strokes": true})), + shadow(10.0, 0.0, 0.0), + ], + ]; + for effects in cases { + let n = fill_square(18, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{effects:?}: drawn {drawn:?}"); + let expanded = shadows_in(&expand_outlined(None, &n, None).unwrap()); + assert!(!expanded.is_empty() && near(&expanded, &vec![(10.0, 0.0); expanded.len()]), "{effects:?}: expanded {expanded:?}"); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = shadows_in(baked.node(NodeId(18)).unwrap()); + assert!(!b.is_empty() && near(&b, &vec![(10.0, 0.0); b.len()]), "{effects:?}: baked {b:?}"); + } +} + +/// A geometry effect after a fill's own Transform effect with Scale Strokes & Effects on reshapes +/// the transformed art by its own distance, as it does after an object's Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_offset_after_it() { + let offset = fx("path.offsetPath", json!({"offset": 5})); + let scaled = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = fill_square(19, vec![scaled.clone(), offset.clone()]); + let on_fill = crate::reshape(None, &n, None).unwrap(); + let on_object = crate::reshape(None, &square(20, vec![scaled, offset]), None).unwrap(); + let (fill, object) = (on_fill.geometric_bounds().unwrap(), on_object.geometric_bounds().unwrap()); + // The 50 pt square scaled to 100 pt about its center, then offset by 5 pt on each side. + assert!((fill.width() - 110.0).abs() < 1e-6 && (fill.height() - 110.0).abs() < 1e-6, "{fill:?}"); + assert!((fill.width() - object.width()).abs() < 1e-6 && (fill.x0 - object.x0).abs() < 1e-6, "{fill:?} {object:?}"); + let expanded = expand_outlined(None, &n, None).unwrap().geometric_bounds().unwrap(); + assert!((expanded.width() - 110.0).abs() < 1e-6 && (expanded.x0 - fill.x0).abs() < 1e-6, "expanded {expanded:?}"); +} + +/// The SVG filters on `n` and the nodes under it, from the object's effects and from each fill's +/// and stroke's. +fn filters_in(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + out.extend(lists.flatten().filter(|e| e.id == crate::SVG_FILTER).cloned()); + }); + out +} + +/// An SVG filter after a fill's own Transform effect with Scale Strokes & Effects on keeps its +/// scale and the origin of its user space, as it does after the same Transform effect without +/// options and after an object's own Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_svg_filter_after_it() { + let filter = fx(crate::SVG_FILTER, json!({"scale": 1.0, "origin": [10.0, 10.0]})); + for params in [ + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "patterns": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + ] { + let art = crate::reshape(None, &fill_square(21, vec![transform(params.clone()), filter.clone()]), None).unwrap(); + let drawn = filters_in(&art); + assert!(!drawn.is_empty() && drawn.iter().all(|e| *e == filter), "{params}: {drawn:?}"); + } + let object = square(22, vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true})), filter.clone()]); + let art = crate::reshape(None, &object, None).unwrap(); + assert!(!filters_in(&art).is_empty() && filters_in(&art).iter().all(|e| *e == filter), "{:?}", filters_in(&art)); +} + +/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the +/// object's and its fill's 3D effect alike. +#[test] +fn a_shadow_before_a_transform_follows_it_into_3d_art() { + let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let mut on_object = before.clone(); + on_object.push(fx(three_d, json!({}))); + let projected = art(&square(14, on_object)).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected)); + let mut on_fill = square(15, before.clone()); + if let Some(item) = on_fill.appearance.items.first_mut() { + *item.effects_mut() = vec![fx(three_d, json!({}))]; + } + let projected = art(&on_fill).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected)); + } +} + +#[test] +fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { + let effects = [ + fx("stylize.outerGlow", json!({"blur": 5})), + fx("stylize.innerGlow", json!({"blur": 5})), + fx("stylize.feather", json!({"radius": 5})), + fx("blur.gaussian", json!({"radius": 5})), + transform(json!({"scaleH": 50, "scaleV": 50})), + ]; + let sizes: Vec = raster_effects(&effects) + .iter() + .map(|r| match r { + RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur, + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius, + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!(sizes, vec![2.5; 4]); + // How far the shadow paints past the object follows too. + let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); +} + +#[test] +fn pixel_filter_sizes_scale_with_the_transform_after_them() { + let effects = [ + fx("artistic.coloredPencil", json!({"pencilWidth": 5})), + fx("artistic.paintDaubs", json!({"brushSize": 10})), + transform(json!({"scaleH": 200, "scaleV": 200})), + ]; + let drawn = raster_effects(&effects); + assert!(matches!(drawn.first(), Some(RasterFx::Pixel(crate::PixelFx::ColoredPencil { width, .. })) if (width - 10.0).abs() < 1e-9), "{drawn:?}"); + assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}"); +} + +/// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow +/// before the 3D effect, as it does without the 3D effect. +#[test] +fn a_copying_transform_after_3d_holds_the_shadow_back() { + for later in [json!({"copies": 1}), json!({"scaleH": 150, "scaleV": 150, "random": true})] { + let plain = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(later.clone())]; + assert!(close(drawn_shadow(&plain), (10.0, 0.0, 0.0)), "{later}: {:?}", drawn_shadow(&plain)); + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let effects = vec![plain[0].clone(), plain[1].clone(), fx(three_d, json!({})), plain[2].clone()]; + let projected = art(&square(16, effects)).unwrap(); + let drawn = shadows_in(&projected); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{three_d} {later}: {drawn:?}"); + } + } +} + +/// In a fill's own effects, a Transform effect that makes copies after a Transform effect with +/// Transform Patterns on holds back the shadow before both, and before a Transform effect between +/// them. +#[test] +fn a_fills_copying_transform_holds_back_the_shadow_before_an_earlier_transform() { + let patterns = transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})); + let copies = transform(json!({"copies": 1})); + let lists = [ + vec![shadow(10.0, 0.0, 0.0), patterns.clone(), copies.clone()], + vec![shadow(10.0, 0.0, 0.0), patterns, transform(json!({"rotate": 90})), copies], + ]; + for effects in lists { + let n = fill_square(17, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{} effects: {drawn:?}", effects.len()); + } +} + +#[test] +fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { + let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + // A path drawn through its art (Scale Strokes & Effects on), expanded. + let mut with_options = effects.clone(); + with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = square(7, with_options); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects); + // A path and a group, baked for export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, square(8, effects.clone())).unwrap(); + let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]); + g.appearance.effects = effects.clone(); + d.insert(Some(layer), 1, g).unwrap(); + let baked = bake_document(&d).unwrap(); + for id in [8, 9] { + let b = baked.node(NodeId(id)).unwrap(); + assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects); + assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects); + } +} diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index de69116b79..1046f3c437 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -222,7 +222,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { } #[test] -fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() { +fn a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects() { let mut n = stroke_with_effect(json!({})); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true}))); @@ -231,10 +231,10 @@ fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects let art = reshape(None, &n, None).unwrap(); let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap(); let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") }; - // The Offset Path, scaled from 5 to 15, applies to the transformed fill's path. + // The Offset Path applies to the transformed fill's path at its own 5 pt. assert!(f.effects.is_empty(), "{:?}", f.effects); let w = fill.geometric_bounds().unwrap().width(); - assert!(close(w, 330.0), "{w}"); + assert!(close(w, 310.0), "{w}"); } #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 465025302f..509279a3ae 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -4,6 +4,7 @@ pub mod actionset; mod addons; mod apiref; pub(crate) mod appearance; +pub mod arrowheads; mod assets; mod attributes; pub mod brushlib; @@ -105,6 +106,7 @@ mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; +mod wireframe; mod xform; use serde::Serialize; @@ -297,6 +299,7 @@ pub fn command_specs() -> &'static [CommandSpec] { // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command // palette, `engine.commands`) show them where they always were. v.extend(stroke::specs()); + v.extend(arrowheads::specs()); v.extend(appearance::specs()); v.extend(style::specs()); v.extend(swatch::specs()); @@ -308,6 +311,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw2::specs()); v.extend(xform::specs()); v.extend(effectcmd::specs()); + v.extend(wireframe::specs()); v.extend(pathops::specs()); v.extend(compoundshape::specs()); v.extend(typecmd::specs()); @@ -771,6 +775,7 @@ pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; +pub use plugin_call::{Location, key_location, location_in, location_keys}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/addons/color/legend.rs b/crates/engine/src/cmd/addons/color/legend.rs index e2f38a7a17..e6942378be 100644 --- a/crates/engine/src/cmd/addons/color/legend.rs +++ b/crates/engine/src/cmd/addons/color/legend.rs @@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result { let parent = st.target_parent()?; let text_size = (size / 7.0).clamp(4.0, 12.0); let pad = (size / 12.0).clamp(1.0, 8.0); - let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size })); + let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }), C)?; let count = swatches.len(); // Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or // spot swatch then recolours the legend too. diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 8412af9a68..2734ca10b3 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -1,11 +1,16 @@ //! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil //! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together -//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's. +//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art +//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are +//! the UI's. mod stencil; +mod weed; use super::super::CommandSpec; pub fn specs() -> Vec { - stencil::specs() + let mut v = stencil::specs(); + v.extend(weed::specs()); + v } diff --git a/crates/engine/src/cmd/addons/cutting/stencil.rs b/crates/engine/src/cmd/addons/cutting/stencil.rs index a8b4cfe591..dc415ce73d 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil.rs @@ -89,7 +89,12 @@ fn bridges(s: &mut Session, p: &Value) -> Result { }; let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); let spec = BridgeSpec { width, count: count as usize, direction }; - let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?; + let bridged = stencil_bridges(&refs, spec).map_err(|e| match e { + vectorcraft_pathops::PathOpsError::TooComplex => { + EngineError::Other("Stencil Bridges: the selected art is too large or too detailed to bridge".into()) + } + e => EngineError::Other(e.to_string()), + })?; (bridged, style_of(&top), roots) }; let summary = |id: Option, original: Option| { diff --git a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs index ad072ca13d..b21f0fef5c 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs @@ -277,6 +277,34 @@ fn bad_params_are_refused() { assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run"); } +#[test] +fn art_too_detailed_to_bridge_is_refused_with_a_message_that_says_so() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("dense", |d, sel| { + // More anchors than Stencil Bridges measures. + let mut zigzag = kurbo::BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=vectorcraft_pathops::addons::stencil::MAX_STENCIL_SEGMENTS { + zigzag.line_to((100.0 + i as f64 * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + zigzag.close_path(); + let huge = vectorcraft_geom::PathData::from_bezpath(&zigzag); + let n = vectorcraft_doc::Node::path(d.alloc_id(), huge, vectorcraft_doc::Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let steps = undo_steps(&s); + let e = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap_err().to_string(); + assert!(e.contains("Stencil Bridges: the selected art is too large or too detailed to bridge"), "{e}"); + assert_eq!(undo_steps(&s), steps); +} + #[test] fn selected_objects_without_outlines_stay_where_they_are() { for keep in [true, false] { diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs new file mode 100644 index 0000000000..a05770a024 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -0,0 +1,160 @@ +//! Weed Box and Lines (`addon.cutting.weed`), a command of the built-in Cutting Tools add-on for +//! vinyl cutting: around the selected art it adds a weed box (a rectangle at a margin around the +//! art) and weeding lines across it that stop at the art, so the waste vinyl around the art lifts +//! off in pieces (). The art is measured by the +//! outlines a cutter cuts ([`geometry`]). + +mod geometry; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::object::choice; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::{length_given, number_given}; +use super::super::super::{CommandSpec, bad, cmd, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Weed Box and Lines' command. +pub const WEED: &str = "addon.cutting.weed"; +/// Which way the weeding lines run. +pub const DIRECTIONS: [&str; 3] = ["horizontal", "vertical", "both"]; +/// The largest margin, gap or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most weeding lines each way. +const MAX_COUNT: usize = 1000; +/// The most pieces of weeding line one run adds. +const MAX_PIECES: usize = 100_000; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.weed", + "Weed Box and Lines…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{margin?: pt 0..100000 (9: from the art to the weed box), box?: true (false: the weeding lines only), direction?: \"horizontal\" | \"vertical\" | \"both\" (\"both\"), count?: whole number 0..1000 of lines each way (1), spacing?: pt (instead of count: as few lines as keep them at most this far apart), gap?: pt 0..100000 (0: the lines stop at the art's outlines), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the lines are stroked in (\"[Registration]\")} add a weed box around the selected art and weeding lines across it that divide it evenly, each running from one side of the box to the other and left out over the art and within `gap` of its outlines, so the waste lifts off in pieces. The art is measured by the outlines a cutter cuts: closed paths enclose their area by their fill rule and open paths are lines, compound paths by all their subpaths, type by its glyph outlines, a clip group by what shows inside its clipping path, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art and live objects by the art they draw; strokes, effects and hidden objects are left out. One group named Weeding, stroked with no fill, on top of the current layer (outside the clip groups around a group edited in isolation), selected, as one undo step → {id: the group, box: its weed box or null, lines (pieces of weeding line)}", + has_selection, + weed + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, WEED)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(WEED, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// How many lines divide a side `length` long: `count`, or as few as keep them at most `spacing` +/// apart. +fn lines_across(length: f64, count: Option, spacing: Option) -> Result { + let n = match spacing { + // `length / spacing` strips, rounded up, need one line fewer. + Some(s) => ((length / s - 1e-9).ceil() - 1.0).max(0.0), + None => return Ok(count.unwrap_or(1)), + }; + if n > MAX_COUNT as f64 { + return Err(bad(WEED, format!("`spacing` makes more than {MAX_COUNT} lines across the box"))); + } + Ok(n as usize) +} + +/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping +/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping +/// path would hide the lines and the box, which lie outside the art. +fn outside_clips(d: &Document, parent: Option) -> Option { + let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); + let (mut at, mut out) = (parent, parent); + while let Some(p) = at { + let up = d.parent_of(p); + if clips(p) { + match up { + Some(up) if d.is_editable(up) => out = Some(up), + _ => break, + } + } + at = up; + } + out +} + +fn weed(s: &mut Session, p: &Value) -> Result { + let margin = distance(p, "margin", 9.0)?; + let gap = distance(p, "gap", 0.0)?; + let with_box = switch_or(p, "box", true, WEED)?; + let direction = choice(p, WEED, "direction", &DIRECTIONS)?.unwrap_or("both"); + let count = match number_given(p, "count", WEED)? { + None => None, + Some(n) if n.fract() == 0.0 && (0.0..=MAX_COUNT as f64).contains(&n) => Some(n as usize), + Some(_) => return Err(bad(WEED, format!("`count` must be a whole number from 0 to {MAX_COUNT}"))), + }; + let spacing = match length_given(p, "spacing", WEED)? { + None => None, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => Some(v), + Some(_) => return Err(bad(WEED, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + if count.is_some() && spacing.is_some() { + return Err(bad(WEED, "give `count` or `spacing`, not both")); + } + let weight = match length_given(p, "weight", WEED)? { + None => 0.3, + Some(v) if (0.01..=1000.0).contains(&v) => v, + Some(_) => return Err(bad(WEED, "`weight` must be from 0.01 to 1000 pt")), + }; + let swatch = text_param(p, "swatch", WEED)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(WEED, e))?; + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(WEED, "select the art to weed")); + } + let d = &s.doc()?.doc; + let parent = outside_clips(d, s.doc()?.target_parent()?); + // Placed files' art uses their resources (symbols) under names of their own. + let placed = d.with_placed_art(); + let region = geometry::Reader::new(&placed).read_ids(&roots).map_err(|e| bad(WEED, e))?; + let region = region.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?; + let outline = region.outline().map_err(|e| bad(WEED, e))?; + let art = outline.iter().fold(None, |r: Option, &(a, b)| Some(r.map_or(Rect::from_points(a, b), |r| r.union_pt(a).union_pt(b)))); + let frame = art.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?.inflate(margin, margin); + if !(frame.width().is_finite() && frame.height().is_finite()) { + return Err(bad(WEED, "the selected art is too large to measure")); + } + let each = |on: bool, length: f64| if on { lines_across(length, count, spacing) } else { Ok(0) }; + let counts = + geometry::Counts { horizontal: each(direction != "vertical", frame.height())?, vertical: each(direction != "horizontal", frame.width())? }; + let pieces = geometry::lines(®ion, &outline, frame, counts, gap, MAX_PIECES).map_err(|e| bad(WEED, e))?; + if pieces.is_empty() && !with_box { + return Err(EngineError::Other("Weed Box and Lines: no weeding line fits outside the art, and the weed box is off".into())); + } + let ap = Appearance::basic(Paint::None, stroke, weight); + let (group, weed_box, lines) = s.edit("Weed Box and Lines", |d, sel| { + let mut children = vec![]; + let mut weed_box = None; + if with_box { + let mut b = Node::path(d.alloc_id(), shapes::rectangle(frame), ap.clone()); + b.name = Some("Weed Box".into()); + weed_box = Some(b.id); + children.push(Arc::new(b)); + } + for (a, b) in &pieces { + children.push(Arc::new(Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()))); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Weeding".into()); + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok((id, weed_box, pieces.len())) + })?; + Ok(json!({ "id": group.0, "box": weed_box.map(|b| b.0), "lines": lines })) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs new file mode 100644 index 0000000000..4642031e71 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs @@ -0,0 +1,769 @@ +//! The geometry of Weed Box and Lines: the selected art as the outlines a cutter cuts ([`Region`]), +//! and the weeding lines across a box that stop at those outlines or at a gap from them +//! ([`lines`]). +//! +//! A region is measured along one straight line at a time. A closed outline encloses its area by +//! its fill rule, an open one is a line with no area, a group is the union of its members and a +//! clip group is what its members draw inside its clipping path. Along a weeding line the region +//! gives the stretches inside its area ([`Region::spans`]), and its outline pieces +//! ([`Region::outline`]) give the stretches within the gap of an outline. + +use std::cell::Cell; + +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{BezPath, FillRule, PathEl, Point, Rect}; + +use super::super::super::super::pathops::node_path; + +/// How closely curves are followed, in points. +const TOLERANCE: f64 = 0.02; +/// The most points the outlines of one selection are measured by, once curves are followed in +/// straight segments (each subpath's points, its first one included). +pub(super) const MAX_POINTS: usize = 200_000; +/// The most tests of a segment against another one measuring a clip group's outlines takes. +const MAX_PAIRS: usize = 40_000_000; +/// The deepest groups, symbol instances and live objects are opened inside one another: as deep +/// as any object is drawn. +pub(super) const MAX_DEPTH: usize = vectorcraft_doc::depth::MAX_DRAWN_DEPTH; +/// The most pairs of an outline segment and a weeding line it comes near that one run measures. +const MAX_NEAR: usize = 10_000_000; +/// Pieces of weeding line shorter than this, in points, are left out. +const MIN_PIECE: f64 = 0.01; + +/// A straight segment. +pub(super) type Segment = (Point, Point); + +/// One subpath followed in straight segments. +#[derive(Clone, Debug)] +pub(super) struct Poly { + points: Vec, + closed: bool, + /// The box around `points`. + bounds: Rect, +} + +impl Poly { + /// The segments, with the closing one of a closed subpath. + fn segments(&self) -> impl Iterator + '_ { + let closing = self.closed.then(|| (self.points.last().copied(), self.points.first().copied())); + self.points.windows(2).map(|w| (w[0], w[1])).chain(closing.and_then(|(a, b)| Some((a?, b?)))) + } +} + +/// The selected art as the outlines a cutter cuts. +#[derive(Clone, Debug)] +pub(super) enum Region { + /// A path, compound path or type: its subpaths with the rule its closed ones fill by. + Shape { polys: Vec, rule: FillRule }, + /// Everything its members draw. + Union(Vec), + /// What `content` draws inside `clip`. + Clip { clip: Box, content: Box }, +} + +/// Reads the selected objects into a [`Region`], keeping count of the outline points. +pub(super) struct Reader<'a> { + doc: &'a Document, + points: usize, +} + +impl<'a> Reader<'a> { + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, points: 0 } + } + + /// The region of `nodes` together, `None` when they hold no outlines. + pub(super) fn read(&mut self, nodes: &[&Node]) -> Result, String> { + let mut parts = vec![]; + for n in nodes { + parts.extend(self.node(n, 0)?); + } + Ok(union(parts)) + } + + fn node(&mut self, n: &Node, depth: usize) -> Result, String> { + if depth > MAX_DEPTH { + return Err(format!("the selected art is nested more than {MAX_DEPTH} levels deep")); + } + if !n.visible { + return Ok(None); + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => Ok(None), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => self.shape(n), + NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } => { + let members = n.editing_children(); + if *clip { + let Some((first, rest)) = members.split_first() else { return Ok(None) }; + let Some(clip) = self.clip_region(first)? else { return Ok(None) }; + let mut parts = vec![]; + for c in rest { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts).map(|content| Region::Clip { clip: Box::new(clip), content: Box::new(content) })) + } else { + let mut parts = vec![]; + for c in members { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts)) + } + } + NodeKind::Image(im) => { + let (w, h) = (f64::from(im.width), f64::from(im.height)); + let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| im.xf * p); + self.polygon(&corners) + } + // An instance of a symbol the document doesn't have draws nothing. + NodeKind::SymbolInstance { .. } => match vectorcraft_render::effects::outline_object(Some(self.doc), n) { + Some(art) => self.node(&art, depth + 1), + None => Ok(None), + }, + NodeKind::PlacedDocument(p) => { + // The art of the file it shows; a file that can't be read shows its preview in its + // box, which may be rotated. + if vectorcraft_doc::placed_document::read_cached(p).is_none() { + return self.polygon(&p.corners()); + } + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { + self.polygon(&p.corners()) + } else { + self.node(&art, depth + 1) + } + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + // The art a live object draws, as the exporters write it; an object that stays + // as it is is measured by its box. + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { self.frame(n) } else { self.node(&art, depth + 1) } + } + } + } + + /// The region a clipping path `first` clips to, whatever its own visibility: the region the + /// renderer clips to (a path, a compound path, type by its glyphs, a group's visible members, + /// an image's frame). `None` when it clips to nothing, and the clipped art is hidden. + fn clip_region(&mut self, first: &Node) -> Result, String> { + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(first) else { return Ok(None) }; + let mut polys = self.polys(outline)?; + // A region is filled: an open subpath clips as if closed. + polys.iter_mut().for_each(|p| p.closed = true); + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The region of the objects `ids` together, each within the clipping paths of the clip groups + /// and layers around it (an object selected inside a clip group shows only within its clipping + /// path, and not at all inside a hidden group or layer; a clipping path is measured as its clip + /// group), `None` when they show no outlines. + pub(super) fn read_ids(&mut self, ids: &[NodeId]) -> Result, String> { + let doc = self.doc; + let clips = |n: &Node| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + // Every object's place in the tree, found in one walk. + let places = doc.index_paths(ids.iter().copied()); + let mut paths = vec![]; + for id in ids { + let Some(mut path) = places.get(id).cloned() else { continue }; + // A clipping path selected on its own stands for the art it shows: its clip group. + while let (Some(n), Some(up)) = (doc.node_at(&path), path.split_last().map(|(_, up)| up).and_then(|up| doc.node_at(up))) { + if !(clips(up) && up.editing_children().first().is_some_and(|f| f.id == n.id)) { + break; + } + path.pop(); + } + paths.push(path); + } + // An object selected twice, or inside another selected object, is measured once, with it. + paths.sort(); + paths.dedup(); + let mut kept: Vec> = vec![]; + for path in paths { + if !kept.last().is_some_and(|k| path.starts_with(k)) { + kept.push(path); + } + } + let mut parts = vec![]; + 'ids: for path in &kept { + let Some(n) = doc.node_at(path) else { continue }; + // Art in a hidden group or layer isn't shown, and isn't measured. + if (1..path.len()).any(|depth| path.get(..depth).and_then(|p| doc.node_at(p)).is_some_and(|c| !c.visible)) { + continue; + } + let Some(mut region) = self.read(&[n])? else { continue }; + // The containers around the object, innermost first. + let mut child = n.id; + for depth in (1..path.len()).rev() { + let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break }; + if clips(container) + && let Some(first) = container.editing_children().first().filter(|f| f.id != child) + { + let Some(clip) = self.clip_region(first)? else { continue 'ids }; + region = Region::Clip { clip: Box::new(clip), content: Box::new(region) }; + } + child = container.id; + } + parts.push(region); + } + Ok(union(parts)) + } + + /// A path, compound path or type as its outlines. + fn shape(&mut self, n: &Node) -> Result, String> { + // Each anchor is at least one point, so a path past the budget errs before it is copied. + if anchors(n) > MAX_POINTS.saturating_sub(self.points) { + return Err(too_many()); + } + let Some((path, rule)) = node_path(n) else { return Ok(None) }; + let polys = self.polys(path.to_bezpath())?; + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The box of an object whose outlines can't be read. + fn frame(&mut self, n: &Node) -> Result, String> { + match n.geometric_bounds_with(&self.doc.symbol_boxes()) { + Some(r) => self.polygon(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]), + None => Ok(None), + } + } + + /// A closed polygon through `points`. + fn polygon(&mut self, points: &[Point]) -> Result, String> { + let poly = poly(points.to_vec(), true); + self.count(poly.iter().map(|p| p.points.len()).sum())?; + Ok(poly.map(|p| Region::Shape { polys: vec![p], rule: FillRule::NonZero })) + } + + fn count(&mut self, n: usize) -> Result<(), String> { + self.points = self.points.saturating_add(n); + if self.points > MAX_POINTS { Err(too_many()) } else { Ok(()) } + } + + /// The subpaths of `path` followed in straight segments. + fn polys(&mut self, path: BezPath) -> Result, String> { + let (mut out, mut current, points) = (vec![], vec![], Cell::new(0usize)); + let left = MAX_POINTS.saturating_sub(self.points); + // Past the budget no further curve is followed (one curve gives at most + // `MAX_FLATTEN_PER_CURVE` points), and the read fails below. + let elements = path.into_iter().take_while(|_| points.get() <= left); + vectorcraft_geom::flatten(elements, TOLERANCE, |el| { + points.set(points.get() + 1); + if points.get() > left { + return; + } + match el { + PathEl::MoveTo(p) => { + out.extend(poly(std::mem::take(&mut current), false)); + current.push(p); + } + PathEl::LineTo(p) => current.push(p), + PathEl::ClosePath => out.extend(poly(std::mem::take(&mut current), true)), + PathEl::QuadTo(..) | PathEl::CurveTo(..) => {} + } + }); + if points.get() > left { + return Err(too_many()); + } + out.extend(poly(current, false)); + self.count(out.iter().map(|p: &Poly| p.points.len()).sum())?; + Ok(out) + } +} + +fn too_many() -> String { + format!("the selected art has too many outline points to measure (more than {MAX_POINTS})") +} + +/// The anchors of a path or compound path node, read without copying its path; 0 for type. +fn anchors(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { path, .. } => path.anchor_count(), + NodeKind::Compound { children, .. } => children.iter().fold(0, |sum, c| sum.saturating_add(anchors(c))), + _ => 0, + } +} + +/// A subpath through `points`, `None` for fewer than two points or one that isn't finite. +fn poly(points: Vec, closed: bool) -> Option { + if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return None; + } + let first = *points.first()?; + let bounds = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)); + Some(Poly { points, closed, bounds }) +} + +/// One region of `parts`, `None` when there are none. +fn union(mut parts: Vec) -> Option { + match parts.len() { + 0 => None, + 1 => parts.pop(), + _ => Some(Region::Union(parts)), + } +} + +/// `p` as (along, across) a weeding line: horizontal lines run along x, vertical ones along y. +fn uv(p: Point, vertical: bool) -> (f64, f64) { + if vertical { (p.y, p.x) } else { (p.x, p.y) } +} + +/// Sorts `spans` and joins those that overlap or touch. +fn merged(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len()); + for (a, b) in spans { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// Where sorted, joined spans `a` and `b` overlap. +fn intersected(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let (mut i, mut j, mut out) = (0, 0, vec![]); + while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) { + let (lo, hi) = (a0.max(b0), a1.min(b1)); + if lo <= hi { + out.push((lo, hi)); + } + if a1 < b1 { + i += 1; + } else { + j += 1; + } + } + out +} + +/// Where segment a→b crosses the line `across` = `c`, along it, and which way (1 when `across` +/// grows from a to b), `None` when it doesn't. Each crossing counts once: an end exactly on the +/// line counts as past it. +fn crosses(a: Point, b: Point, vertical: bool, c: f64) -> Option<(f64, i32)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + ((v0 <= c && v1 > c) || (v1 <= c && v0 > c)).then(|| (u0 + (c - v0) * (u1 - u0) / (v1 - v0), if v1 > v0 { 1 } else { -1 })) +} + +/// The stretches inside an area filled by `rule`, from where its outlines cross a line, sorted +/// along it. +fn filled(crossings: impl IntoIterator, rule: FillRule) -> Vec<(f64, f64)> { + let (mut winding, mut start, mut out) = (0, 0.0, vec![]); + let inside = |w: i32| match rule { + FillRule::NonZero => w != 0, + FillRule::EvenOdd => w % 2 != 0, + }; + for (u, dir) in crossings { + let was = inside(winding); + winding += dir; + match (was, inside(winding)) { + (false, true) => start = u, + (true, false) => out.push((start, u)), + _ => {} + } + } + merged(out) +} + +/// Where the closed outlines of a region's shapes cross a set of weeding lines: for each line, the +/// crossings as (shape, place along the line, direction), sorted by shape and then by place. Shapes +/// are numbered in the order [`Region::shapes`] lists them. +pub(super) type Crossings = Vec>; + +/// Where the shapes of each part of a region are numbered among its [`Crossings`]. +#[derive(Debug)] +pub(super) enum Layout { + Shape, + /// The number of each part's first shape, from the union's first, and the parts' layouts. + Union { + starts: Vec, + parts: Vec, + }, + /// The clipping path's shapes come first. + Clip { + clip_shapes: usize, + clip: Box, + content: Box, + }, +} + +impl Layout { + /// The layout of `region` and how many shapes it has. + pub(super) fn of(region: &Region) -> (Layout, usize) { + match region { + Region::Shape { .. } => (Layout::Shape, 1), + Region::Union(parts) => { + let (mut starts, mut layouts, mut n) = (vec![], vec![], 0); + for r in parts { + let (l, k) = Layout::of(r); + starts.push(n); + layouts.push(l); + n += k; + } + (Layout::Union { starts, parts: layouts }, n) + } + Region::Clip { clip, content } => { + let ((lc, a), (lt, b)) = (Layout::of(clip), Layout::of(content)); + (Layout::Clip { clip_shapes: a, clip: Box::new(lc), content: Box::new(lt) }, a + b) + } + } + } +} + +/// The [`Crossings`] of `region` with the lines `across` = v0 + (k + 1)·step, k < `count`. Errs +/// past [`MAX_NEAR`] crossings in all. +pub(super) fn crossings(region: &Region, vertical: bool, v0: f64, step: f64, count: usize) -> Result { + let mut shapes = vec![]; + region.shapes(&mut shapes); + let at = |k: usize| v0 + step * (k as f64 + 1.0); + // The lines a segment may cross: those from just below its lower end to its higher end. + let candidates = |(a, b): Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + if step <= 0.0 || va == vb { + return 0..0; + } + let first = (((va.min(vb) - v0) / step).floor() - 1.0).clamp(0.0, count as f64); + let end = ((va.max(vb) - v0) / step).ceil().clamp(0.0, count as f64); + (first as usize)..(end as usize) + }; + let closed = |polys: &[Poly]| polys.iter().filter(|p| p.closed).flat_map(Poly::segments).collect::>(); + let total = shapes.iter().flat_map(|(polys, _)| closed(polys)).fold(0usize, |n, s| n.saturating_add(candidates(s).len())); + if total > MAX_NEAR { + return Err("the weeding lines cross the art's outlines too many times to measure; use fewer lines".into()); + } + let mut out: Crossings = vec![vec![]; count]; + for (i, (polys, _)) in shapes.iter().enumerate() { + for (a, b) in closed(polys) { + for k in candidates((a, b)) { + if let (Some((u, dir)), Some(line)) = (crosses(a, b, vertical, at(k)), out.get_mut(k)) { + line.push((i, u, dir)); + } + } + } + } + for line in &mut out { + line.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + } + Ok(out) +} + +impl Region { + /// The stretches of the line `across` = `c` (horizontal: y = c, along x; vertical: x = c, + /// along y) inside the region's area, sorted and joined. + pub(super) fn spans(&self, vertical: bool, c: f64) -> Vec<(f64, f64)> { + match self { + Region::Shape { polys, rule } => { + let mut crossings: Vec<(f64, i32)> = vec![]; + for p in polys.iter().filter(|p| p.closed) { + let (lo, hi) = if vertical { (p.bounds.x0, p.bounds.x1) } else { (p.bounds.y0, p.bounds.y1) }; + if c >= lo && c <= hi { + crossings.extend(p.segments().filter_map(|(a, b)| crosses(a, b, vertical, c))); + } + } + crossings.sort_by(|a, b| a.0.total_cmp(&b.0)); + filled(crossings, *rule) + } + Region::Union(parts) => merged(parts.iter().flat_map(|r| r.spans(vertical, c)).collect()), + Region::Clip { clip, content } => intersected(&clip.spans(vertical, c), &content.spans(vertical, c)), + } + } + + /// The region's shapes with their fill rules, in a fixed order. + fn shapes<'a>(&'a self, out: &mut Vec<(&'a [Poly], FillRule)>) { + match self { + Region::Shape { polys, rule } => out.push((polys, *rule)), + Region::Union(parts) => parts.iter().for_each(|r| r.shapes(out)), + Region::Clip { clip, content } => { + clip.shapes(out); + content.shapes(out); + } + } + } + + /// [`Region::spans`] on a weeding line, from `line`, the line's [`Crossings`] with the + /// region's shapes, numbered from `base` (`layout` is the region's [`Layout`]). Only the parts + /// the line crosses are visited: a part it doesn't cross has no stretch inside its area. + pub(super) fn spans_on(&self, layout: &Layout, line: &[(usize, f64, i32)], base: usize) -> Vec<(f64, f64)> { + if line.is_empty() { + return vec![]; + } + match (self, layout) { + (Region::Shape { rule, .. }, Layout::Shape) => filled(line.iter().map(|&(_, u, dir)| (u, dir)), *rule), + (Region::Union(parts), Layout::Union { starts, parts: layouts }) => { + let (mut out, mut rest) = (vec![], line); + while let Some(&(id, ..)) = rest.first() { + // The part whose shapes the next crossing is on, and its crossings. + let j = starts.partition_point(|&s| base + s <= id).saturating_sub(1); + let (Some(r), Some(l), Some(&start)) = (parts.get(j), layouts.get(j), starts.get(j)) else { break }; + let end = starts.get(j + 1).map_or(usize::MAX, |&s| base + s); + let n = rest.partition_point(|c| c.0 < end); + if n == 0 { + break; + } + let (mine, after) = rest.split_at(n); + out.extend(r.spans_on(l, mine, base + start)); + rest = after; + } + merged(out) + } + (Region::Clip { clip, content }, Layout::Clip { clip_shapes, clip: lc, content: lt }) => { + let (a, b) = line.split_at(line.partition_point(|c| c.0 < base + clip_shapes)); + if a.is_empty() || b.is_empty() { + return vec![]; + } + intersected(&clip.spans_on(lc, a, base), &content.spans_on(lt, b, base + clip_shapes)) + } + _ => vec![], + } + } + + /// Is `p` inside the region's area or on one of `segments`, the outlines it shows? + fn contains(&self, p: Point, segments: &[Segment]) -> bool { + const EPS: f64 = 1e-7; + self.spans(false, p.y).iter().any(|&(a, b)| p.x >= a - EPS && p.x <= b + EPS) || segments.iter().any(|&(a, b)| distance(p, a, b) <= EPS) + } + + /// The pieces of outline the region shows: every outline of a shape and of a group's members, + /// and in a clip group the members' outlines inside the clipping path and the clipping path's + /// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`], counted over + /// every clip group in the region together. + pub(super) fn outline(&self) -> Result, String> { + self.outline_counted(&mut 0) + } + + /// [`Region::outline`], adding the measure's work to `work`. + fn outline_counted(&self, work: &mut usize) -> Result, String> { + match self { + Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()), + Region::Union(parts) => { + let mut out = vec![]; + for r in parts { + out.extend(r.outline_counted(work)?); + } + Ok(out) + } + Region::Clip { clip, content } => { + // Each side's area is bounded by the outlines it shows, so the pieces are cut and + // tested against those, never against art a nested clip hides. + let (clip_shown, content_shown) = (clip.outline_counted(work)?, content.outline_counted(work)?); + let mut out = within(&content_shown, clip, &clip_shown, work)?; + out.extend(within(&clip_shown, content, &content_shown, work)?); + Ok(out) + } + } + } + + /// The points of the region's closed outlines, those a nested clip hides included: what one + /// [`Region::spans`] looks at, at most. + fn closed_points(&self) -> usize { + match self { + Region::Shape { polys, .. } => polys.iter().filter(|p| p.closed).map(|p| p.points.len()).sum(), + Region::Union(parts) => parts.iter().map(Region::closed_points).sum(), + Region::Clip { clip, content } => clip.closed_points().saturating_add(content.closed_points()), + } + } +} + +fn too_complex_clip() -> String { + "a clip group in the selection has too many outline segments to measure".into() +} + +/// The pieces of `segments` inside `region` (or on its outlines), cut where they cross +/// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested +/// for being inside, adds the region's closed outline points (hidden ones included) and its +/// shown segment count to `work`, and the measure errs past [`MAX_PAIRS`]. +fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result, String> { + let mut out = vec![]; + let cost = region.closed_points().saturating_add(region_segments.len()).max(1); + let charge = |work: &mut usize| { + *work = work.saturating_add(cost); + if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) } + }; + for &(a, b) in segments { + charge(work)?; + let mut cuts = vec![0.0, 1.0]; + let seg_box = Rect::from_points(a, b); + for &(c, d) in region_segments { + if touches(seg_box, Rect::from_points(c, d)) { + cuts.extend(crossing(a, b, c, d)); + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); + let at = |t: f64| a + (b - a) * t; + for w in cuts.windows(2) { + let (t0, t1) = (w[0], w[1]); + if t1 - t0 < 1e-12 { + continue; + } + charge(work)?; + if region.contains(at((t0 + t1) / 2.0), region_segments) { + out.push((at(t0), at(t1))); + } + } + } + Ok(out) +} + +/// Do boxes `a` and `b` overlap or touch? +fn touches(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Where along a→b (0 to 1) segment c→d crosses or touches it: one place, or the ends of the +/// shared stretch when the two lie on one line. +fn crossing(a: Point, b: Point, c: Point, d: Point) -> Vec { + let (r, s) = (b - a, d - c); + let denom = r.cross(s); + let qp = c - a; + let len2 = r.hypot2(); + if len2 == 0.0 { + return vec![]; + } + if denom.abs() <= 1e-12 * len2.sqrt() * s.hypot().max(1e-300) { + // Parallel: the ends of c→d on a→b when the two lie on one line. + if qp.cross(r).abs() > 1e-9 * len2.sqrt() { + return vec![]; + } + return [c, d].iter().map(|p| (*p - a).dot(r) / len2).filter(|t| (0.0..=1.0).contains(t)).collect(); + } + let t = qp.cross(s) / denom; + let u = qp.cross(r) / denom; + if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { vec![t] } else { vec![] } +} + +/// The distance from `p` to segment a→b. +fn distance(p: Point, a: Point, b: Point) -> f64 { + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p - (a + ab * t)).hypot() +} + +/// The stretch of the line `across` = `c` within `gap` of segment a→b, `None` when the line +/// passes farther away. With no gap it is where the segment meets the line. +fn near(a: Point, b: Point, vertical: bool, c: f64, gap: f64) -> Option<(f64, f64)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + if gap <= 0.0 { + if v0 == v1 { + return (v0 == c).then_some((u0.min(u1), u0.max(u1))); + } + if c < v0.min(v1) || c > v0.max(v1) { + return None; + } + let u = u0 + (c - v0) * (u1 - u0) / (v1 - v0); + return Some((u, u)); + } + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let mut take = |x: f64, y: f64| { + lo = lo.min(x); + hi = hi.max(y); + }; + // The round ends. + for (u, v) in [(u0, v0), (u1, v1)] { + let dv = (c - v).abs(); + if dv <= gap { + let h = (gap * gap - dv * dv).max(0.0).sqrt(); + take(u - h, u + h); + } + } + // The band along the segment: points whose foot falls on it, within the gap of it. + let (du, dv) = (u1 - u0, v1 - v0); + let len = du.hypot(dv); + if len > 0.0 { + // Each condition is linear in the position x along the line: k·x + m ≥ 0. + let mut range = (f64::NEG_INFINITY, f64::INFINITY); + let mut keep = |k: f64, m: f64| { + if k == 0.0 { + if m < 0.0 { + range = (1.0, 0.0); + } + } else if k > 0.0 { + range.0 = range.0.max(-m / k); + } else { + range.1 = range.1.min(-m / k); + } + }; + // The foot at or after a: (x - u0)·du + (c - v0)·dv ≥ 0. + keep(du, -u0 * du + (c - v0) * dv); + // The foot at or before b. + keep(-du, u0 * du - (c - v0) * dv + len * len); + // Within the gap on either side: |(x - u0)·dv - (c - v0)·du| ≤ gap·len. + let cross0 = -u0 * dv - (c - v0) * du; + keep(-dv, -cross0 + gap * len); + keep(dv, cross0 + gap * len); + if range.0 <= range.1 { + take(range.0, range.1); + } + } + (lo <= hi).then_some((lo, hi)) +} + +/// How many weeding lines run each way. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Counts { + pub horizontal: usize, + pub vertical: usize, +} + +/// The weeding lines across `frame`: `n.horizontal` horizontal and `n.vertical` vertical lines that +/// divide it evenly, each from one side of the frame to the other, left out where it is inside the +/// region's area or within `gap` of one of its outlines (`outline`). Pieces are in order along each +/// line, the horizontal lines first, top to bottom. Errs past [`MAX_NEAR`] pairs of an outline +/// segment and a line it comes within `gap` of, past [`MAX_NEAR`] crossings of the region's +/// outlines with the lines one way, or past `max_pieces` pieces. +pub(super) fn lines(region: &Region, outline: &[Segment], frame: Rect, n: Counts, gap: f64, max_pieces: usize) -> Result, String> { + let mut out = vec![]; + let (layout, _) = Layout::of(region); + for (vertical, count) in [(false, n.horizontal), (true, n.vertical)] { + let ((u0, v0), (u1, v1)) = (uv(Point::new(frame.x0, frame.y0), vertical), uv(Point::new(frame.x1, frame.y1), vertical)); + let step = (v1 - v0) / (count as f64 + 1.0); + // A frame with no height (for horizontal lines) or no width has no waste to divide. + if step <= 0.0 { + continue; + } + // The lines (0-based) each outline segment comes within `gap` of: lines k (1-based) lie at + // v0 + k·step. + let reach = |&(a, b): &Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + let (lo, hi) = (va.min(vb) - gap, va.max(vb) + gap); + let first = ((lo - v0) / step).ceil().max(1.0); + let last = ((hi - v0) / step).floor().min(count as f64); + if step > 0.0 && first <= last { (first as usize - 1)..(last as usize) } else { 0..0 } + }; + // The outlines near each line, found once for all of them, by index into `outline`. + let pairs = outline.iter().fold(0usize, |sum, s| sum.saturating_add(reach(s).len())); + if pairs > MAX_NEAR { + return Err("the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap".into()); + } + let mut near_line: Vec> = vec![vec![]; count]; + for (j, s) in outline.iter().enumerate() { + for k in reach(s) { + if let Some(list) = near_line.get_mut(k) { + list.push(j); + } + } + } + let crossed = crossings(region, vertical, v0, step, count)?; + for (i, segments) in near_line.iter().enumerate() { + let c = v0 + step * (i as f64 + 1.0); + let mut blocked = region.spans_on(&layout, crossed.get(i).map(Vec::as_slice).unwrap_or_default(), 0); + blocked.extend(segments.iter().filter_map(|&j| outline.get(j)).filter_map(|&(a, b)| near(a, b, vertical, c, gap))); + let mut from = u0; + let point = |u: f64| if vertical { Point::new(c, u) } else { Point::new(u, c) }; + for (a, b) in merged(blocked) { + if a - from >= MIN_PIECE && a <= u1 { + out.push((point(from), point(a.min(u1)))); + } + from = from.max(b); + } + if u1 - from >= MIN_PIECE { + out.push((point(from), point(u1))); + } + if out.len() > max_pieces { + return Err(format!("the weeding lines would be more than {max_pieces} pieces")); + } + } + } + Ok(out) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/tests.rs b/crates/engine/src/cmd/addons/cutting/weed/tests.rs new file mode 100644 index 0000000000..1003ef1db3 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/tests.rs @@ -0,0 +1,915 @@ +//! Tests for `addon.cutting.weed`: a weed box around the selected art and weeding lines across it +//! that stop at the art's outlines. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; + +use super::super::super::super::pathops::text_outline; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Runs Weed Box and Lines with `p` and gives the group it made. +fn weed(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.weed", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +/// The weed box's rectangle in group `g`, if it has one. +fn weed_box(g: &Node) -> Option { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).map(|c| c.geometric_bounds().unwrap()) +} + +/// The weeding lines in group `g` as their two ends, rounded to 1/1000 pt. +fn lines(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + g.children() + .unwrap() + .iter() + .filter(|c| c.name.is_none()) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a weeding line is one straight segment"); + ((r(a[0].x), r(a[0].y)), (r(a[1].x), r(a[1].y))) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +#[test] +fn a_weed_box_and_lines_go_around_a_rectangle() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(g.name.as_deref(), Some("Weeding")); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + // One line each way through the middle, left out over the art. + assert_eq!( + lines(&g), + [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (160.0, 120.0)), ((125.0, 90.0), (125.0, 100.0)), ((125.0, 140.0), (125.0, 150.0))] + ); + assert_eq!(r["lines"], json!(4)); + assert_eq!(r["box"], json!(weed_box_id(&g))); + // Stroked in Registration with no fill, selected, as one undo step that takes it all away. + for c in g.children().unwrap() { + assert_eq!(c.appearance.fill_paint(), Paint::None, "no fill"); + assert!(c.appearance.stroke_paint().is_registration()); + } + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g.id).is_none()); +} + +fn weed_box_id(g: &Node) -> u64 { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).unwrap().id.0 +} + +#[test] +fn lines_stop_at_the_outline_and_cross_the_hole_of_a_compound_path() { + let mut s = session(); + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + // Both subpaths wind the same way, so the fill rule alone decides whether the inner one is a + // hole. + let same_way = |rule: FillRule| { + move |d: &mut vectorcraft_doc::Document, _: &mut vectorcraft_doc::Selection| -> Result<()> { + let Some(NodeKind::Compound { children, rule: r }) = d.node_mut(c).map(|n| &mut n.kind) else { panic!("not a compound path") }; + *r = rule; + let area = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths[0].signed_area(), + _ => panic!("not a path"), + }; + let outer = area(&children[0]).signum(); + for child in children.iter_mut() { + if area(child).signum() != outer + && let NodeKind::Path { path, .. } = &mut std::sync::Arc::make_mut(child).kind + { + path.reverse(); + } + } + Ok(()) + } + }; + s.edit("even-odd", same_way(FillRule::EvenOdd)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The waste in the hole is cut too; the ring itself is not. + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((130.0, 150.0), (170.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); + // With the non-zero rule the inner subpath fills, and no line crosses it. + s.edit("non-zero", same_way(FillRule::NonZero)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn a_gap_keeps_the_lines_away_from_the_outline() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"margin": 20, "gap": 5, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(80.0, 80.0, 170.0, 160.0))); + assert_eq!(lines(&g), [((80.0, 120.0), (95.0, 120.0)), ((155.0, 120.0), (170.0, 120.0))]); + // The gap is a distance from the outline: a line passing 5 pt beside a corner, with a gap of + // 6, stops √11 pt short of the corner's height. + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 60.0, 10.0, 120.0); + select(&mut s, &[a, b]); + let (_, g) = weed(&mut s, json!({"margin": 0, "gap": 6, "direction": "vertical"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 60.0, 210.0, 180.0))); + assert_eq!(lines(&g), [((155.0, 60.0), (155.0, 96.683)), ((155.0, 143.317), (155.0, 180.0))]); +} + +#[test] +fn a_box_with_no_height_gets_no_lines_across_it() { + // A horizontal line with no margin: the box is as flat as the line, and no weeding line runs + // along it. + let mut s = session(); + let l = line(&mut s, &[(100.0, 150.0), (200.0, 150.0)]); + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &json!({"margin": 0, "direction": "horizontal", "box": false})).is_err()); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing to add"); + select(&mut s, &[l]); + let (_, g) = weed(&mut s, json!({"margin": 0, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 150.0, 200.0, 150.0))); + assert_eq!(lines(&g), []); +} + +#[test] +fn an_open_path_stops_a_line_where_it_crosses() { + let mut s = session(); + line(&mut s, &[(100.0, 100.0), (100.0, 200.0)]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal", "gap": 2})); + assert_eq!(lines(&g), [((90.0, 150.0), (98.0, 150.0)), ((102.0, 150.0), (110.0, 150.0))]); +} + +#[test] +fn type_is_measured_by_its_glyph_outlines() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "OHO", "size": 72})).unwrap()); + let glyphs = match &node(&s, t).kind { + NodeKind::Text(t) => text_outline(t), + _ => panic!("not type"), + }; + let ink = glyphs.bounds().unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + for (got, want) in [(b.x0, ink.x0 - 10.0), (b.y0, ink.y0 - 10.0), (b.x1, ink.x1 + 10.0), (b.y1, ink.y1 + 10.0)] { + assert!((got - want).abs() < 0.05, "the box is 10 pt around the glyphs: {b:?} vs {ink:?}"); + } + // No piece of line runs over a glyph: each piece's middle is outside the ink. + let shape = glyphs.to_bezpath(); + let pieces = lines(&g); + assert!(pieces.len() > 3, "the letters break the lines: {pieces:?}"); + for ((x0, y0), (x1, y1)) in pieces { + let mid = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); + assert_eq!(shape.winding(mid), 0, "{mid:?} is outside the glyphs"); + } +} + +#[test] +fn a_clip_group_is_measured_by_what_shows() { + let mut s = session(); + let big = rect(&mut s, 0.0, 0.0, 400.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[big, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g).len(), 4); + // Art that shows only partly: the clip path's edge where the art is, and the art's edge + // inside the clip. + let mut s = session(); + let art = rect(&mut s, 120.0, 0.0, 100.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[art, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); +} + +#[test] +fn a_group_as_the_clipping_path_clips_by_its_members() { + // Make Clipping Mask takes a path, a compound path or type; an opened file can clip by a + // group, as this one does. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let mut r = |x: f64, w: f64, y: f64, h: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::default())) + }; + let (a, b, art) = (r(100.0, 50.0, 100.0, 40.0), r(250.0, 50.0, 100.0, 40.0), r(0.0, 400.0, 0.0, 300.0)); + let clip = Arc::new(Node::group(d.alloc_id(), vec![a, b])); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows through both rectangles, and the line is cut between them too. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 310.0, 150.0))); + assert_eq!(lines(&g), [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (250.0, 120.0)), ((300.0, 120.0), (310.0, 120.0))]); +} + +#[test] +fn lines_near_too_many_outline_segments_are_refused() { + let mut s = session(); + // A zigzag of 12,000 segments; with a gap as tall as the box, each of 1,000 lines comes near + // every segment. + let mut zigzag = BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=12_000 { + zigzag.line_to((100.0 + f64::from(i) * 0.01, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("zigzag", |d, sel| { + let n = Node::path(d.alloc_id(), PathData::from_bezpath(&zigzag), Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"count": 1000, "gap": 50, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + // Fewer lines measure. + s.execute("addon.cutting.weed", &json!({"count": 10, "gap": 50, "direction": "horizontal"})).unwrap(); +} + +/// Adds a clip group, clipping path `clip` over `art` (both unpainted), and selects it. +fn clip_group(s: &mut Session, clip: PathData, art: PathData) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let clip = Arc::new(Node::path(d.alloc_id(), clip, Appearance::default())); + let art = Arc::new(Node::path(d.alloc_id(), art, Appearance::default())); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +#[test] +fn an_open_clipping_path_clips_as_if_closed() { + let mut s = session(); + let mut v = BezPath::new(); + v.move_to((100.0, 100.0)); + v.line_to((150.0, 200.0)); + v.line_to((200.0, 100.0)); + clip_group(&mut s, PathData::from_bezpath(&v), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 300.0))); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows inside the triangle the V closes; the line stops at its sides. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 210.0, 210.0))); + assert_eq!(lines(&g), [((90.0, 150.0), (125.0, 150.0)), ((175.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn art_a_nested_clip_hides_adds_no_outline() { + // A clip group, clipping path A, holding a clip group whose clipping path B shows a small + // rectangle and hides a line that runs along A's bottom edge. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: PathData| Arc::new(Node::path(d.alloc_id(), p, Appearance::default())); + let mut l = BezPath::new(); + l.move_to((0.0, 200.0)); + l.line_to((400.0, 200.0)); + let (b, shown, hidden) = ( + path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0))), + path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0))), + path(PathData::from_bezpath(&l)), + ); + let a = path(shapes::rectangle(Rect::new(50.0, 50.0, 300.0, 200.0))); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, hidden]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 0})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured"); +} + +#[test] +fn art_a_nested_clip_hides_counts_toward_the_clip_limit() { + // A clip group, clipping path A of 20,000 segments, holding a clip group whose small clipping + // path B shows a rectangle and hides a closed zigzag C of 20,000 points across every row of A. + // The inner group shows four outline pieces, but testing each piece of A for being inside it + // looks at all of C. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: BezPath| Arc::new(Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance::default())); + let mut a = BezPath::new(); + a.move_to((0.0, 0.0)); + for i in 1..=20_000 { + a.line_to((f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 0.5 })); + } + a.line_to((400.0, 300.0)); + a.line_to((0.0, 300.0)); + a.close_path(); + let mut c = BezPath::new(); + c.move_to((300.0, 0.0)); + for i in 1..=20_000 { + c.line_to((300.0 + f64::from(i) * 0.002, if i % 2 == 0 { 0.0 } else { 300.0 })); + } + c.close_path(); + let b = path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)).to_bezpath()); + let shown = path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0)).to_bezpath()); + let (a, c) = (path(a), path(c)); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, c]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 10, "count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +#[test] +fn art_selected_inside_a_clip_group_is_measured_by_what_shows() { + // The rectangle runs far outside the clipping path; selected on its own, it still shows only + // inside it. + let mut s = session(); + let clip = shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)); + let g = clip_group(&mut s, clip, shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&w), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); + // The clipping path selected on its own is measured by the art it shows. + let clip_path = node(&s, g).children().unwrap()[0].id; + select(&mut s, &[clip_path]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // With the art selected too, or the group, the group is measured once. + let d = &s.doc().unwrap().doc; + let outline = |ids: &[NodeId]| super::geometry::Reader::new(d).read_ids(ids).unwrap().unwrap().outline().unwrap().len(); + assert_eq!(outline(&[clip_path, art]), outline(&[g])); + assert_eq!(outline(&[g, art, g]), outline(&[g])); +} + +#[test] +fn art_in_a_hidden_group_is_left_out() { + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 50.0, 40.0); + select(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[a]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "nothing selected shows"); + select(&mut s, &[a, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn art_in_a_hidden_group_is_not_measured() { + // Selected art in a hidden group with more outline points than the limit doesn't count toward + // it. + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..=super::geometry::MAX_POINTS as u32 { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let dense = insert(&mut s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[dense, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn clip_outlines_crossing_too_often_are_refused() { + // A clipping path whose 700 edges each run across the art, and open art whose 700 segments + // each run down across the clipping path: every pair crosses, and each of the 490,000 pieces + // of art between crossings would be tested against all 700 edges. + let zigzag = |across: bool| { + let mut p = BezPath::new(); + for i in 0..=700 { + let (along, side) = (100.0 + f64::from(i) * 0.25, if i % 2 == 0 { 100.0 } else { 300.0 }); + let pt = if across { (side, along) } else { (along, side) }; + if i == 0 { p.move_to(pt) } else { p.line_to(pt) } + } + p + }; + let mut clip = zigzag(true); + clip.close_path(); + let mut s = session(); + clip_group(&mut s, PathData::from_bezpath(&clip), PathData::from_bezpath(&zigzag(false))); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +/// Each weeding line's stretches inside the art, found from the crossings indexed once for all the +/// lines, match those found by measuring the whole art along that line. +#[test] +fn indexed_crossings_find_the_same_stretches() { + let mut s = session(); + let mut ids = vec![]; + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + ids.push(id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap())); + let big = rect(&mut s, 220.0, 50.0, 200.0, 200.0); + let window = id_of(&s.execute("shape.ellipse", &json!({"x": 260, "y": 80, "width": 90, "height": 120})).unwrap()); + select(&mut s, &[big, window]); + ids.push(id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap())); + ids.push(id_of(&s.execute("text.create", &json!({"x": 100, "y": 320, "text": "WEED 8", "size": 60})).unwrap())); + ids.push(line(&mut s, &[(90.0, 40.0), (450.0, 380.0)])); + let a = rect(&mut s, 300.0, 300.0, 30.0, 30.0); + let b = id_of(&s.execute("shape.ellipse", &json!({"x": 320, "y": 310, "width": 60, "height": 40})).unwrap()); + select(&mut s, &[a, b]); + ids.push(id_of(&s.execute("object.group", &json!({})).unwrap())); + let d = &s.doc().unwrap().doc; + let nodes: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect(); + let region = super::geometry::Reader::new(d).read(&nodes).unwrap().unwrap(); + let (layout, _) = super::geometry::Layout::of(®ion); + let count = 97; + for vertical in [false, true] { + let (v0, v1) = (30.0, 400.0); + let step = (v1 - v0) / (count as f64 + 1.0); + let crossed = super::geometry::crossings(®ion, vertical, v0, step, count).unwrap(); + assert_eq!(crossed.len(), count); + let mut hit = 0; + for (k, line) in crossed.iter().enumerate() { + let c = v0 + step * (k as f64 + 1.0); + let want = region.spans(vertical, c); + hit += usize::from(!want.is_empty()); + assert_eq!(region.spans_on(&layout, line, 0), want, "line {k}, vertical {vertical}"); + } + assert!(hit > count / 2, "most lines cross the art ({hit})"); + } +} + +#[test] +fn art_nested_too_deep_is_refused() { + // A rectangle inside `levels` groups, each holding the next. A document keeps none this deep, + // but the art symbol instances and live objects draw nests further. + let doc = vectorcraft_doc::Document::new(800.0, 600.0); + let nested = |levels: usize| { + let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()); + for i in 0..levels { + n = Node::group(NodeId(i as u64 + 2), vec![Arc::new(n)]); + } + n + }; + let read = |n: &Node| super::geometry::Reader::new(&doc).read(&[n]); + assert!(read(&nested(super::geometry::MAX_DEPTH)).unwrap().is_some()); + let e = read(&nested(super::geometry::MAX_DEPTH + 1)).unwrap_err(); + assert!(e.contains("nested more than"), "{e}"); +} + +#[test] +fn spacing_divides_the_box_into_strips_no_wider() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + // The box is 60 pt tall: three strips of 20 pt. + let (_, g) = weed(&mut s, json!({"margin": 10, "spacing": 25, "direction": "horizontal", "box": false})); + assert_eq!(weed_box(&g), None); + let ys: Vec = lines(&g).iter().map(|l| l.0.1).collect(); + assert_eq!(ys, [110.0, 110.0, 130.0, 130.0]); + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + assert!(s.execute("addon.cutting.weed", &json!({"spacing": 0.01})).is_err(), "too many lines"); +} + +#[test] +fn the_lines_take_a_swatch() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Cut", "color": "cmyk 0 100 0 0", "spot": true})).unwrap(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"swatch": "Cut", "weight": 1})); + for c in g.children().unwrap() { + match c.appearance.stroke_paint() { + Paint::Solid { swatch, .. } => assert_eq!(swatch.as_deref(), Some("Cut")), + other => panic!("stroke {other:?}"), + } + assert_eq!(c.appearance.stroke_width(), 1.0); + } +} + +/// Adds the object `make` makes with a new id to the current layer, selects it and gives its id. +fn insert(s: &mut Session, make: impl FnOnce(NodeId) -> Node) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("insert", |d, _| { + let n = make(d.alloc_id()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// A 100 pt square turned 45° about (300, 300): a diamond 141.42 pt across. +fn diamond_xf() -> Affine { + Affine::translate((300.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0)) +} + +/// The weeding lines of `g` at height `y` (to 1/1000 pt). +fn at(g: &Node, y: f64) -> Vec<(f64, f64)> { + lines(g).into_iter().filter(|((_, y0), (_, y1))| *y0 == y && *y1 == y).map(|((x0, _), (x1, _))| (x0, x1)).collect() +} + +/// The diamond's weeding with three horizontal lines and a margin of 10: the box, and where the +/// first line's left piece ends. +fn diamond_weeding(s: &mut Session) -> (Rect, f64) { + let (_, g) = weed(s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + let y = ((b.y0 + b.height() / 4.0) * 1000.0).round() / 1000.0; + let first = at(&g, y); + assert_eq!(first.len(), 2, "{first:?}"); + (b, first[0].1) +} + +#[test] +fn an_image_is_measured_by_its_frame_as_turned() { + let mut s = session(); + let im = vectorcraft_doc::ImageObject { + key: "image".into(), + width: 100, + height: 100, + xf: diamond_xf(), + link: None, + placement: Default::default(), + pixels: None, + }; + insert(&mut s, |id| Node::new(id, NodeKind::Image(Box::new(im)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + assert!((b.x0 - (290.0 - half)).abs() < 1e-3 && (b.y1 - (310.0 + half)).abs() < 1e-3, "{b:?}"); + // A quarter of the box down, the line meets the diamond's upper left edge, not its bounds. + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_placed_file_is_measured_by_the_art_it_shows() { + let dir = crate::tests_links::Folder::new("weed-placed"); + // A file with a 60 × 40 artboard and a 20 pt wide rectangle at each side (hidden when + // `hidden`), placed centered on (200, 150) and selected. + let place = |s: &mut Session, name: &str, hidden: bool| { + let src = dir.file(name); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let layer = d.layers[0].id; + for x in [0.0, 40.0] { + let id = d.alloc_id(); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let mut n = Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 40.0)), look); + n.visible = !hidden; + d.insert(Some(layer), 0, n).unwrap(); + } + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + }; + let mut s = session(); + place(&mut s, "pair.vectorcraft", false); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The rectangles lie at 170..190 and 210..230; the line runs between them too. + assert_eq!(at(&g, 150.0), [(160.0, 170.0), (190.0, 210.0), (230.0, 240.0)]); + // A file read but shown in a box with no size shows nothing, and its box isn't measured. + let id = NodeId(s.execute("file.place", &json!({"path": dir.file("pair.vectorcraft"), "at": [400, 150]})).unwrap()["ids"][0].as_u64().unwrap()); + s.edit("no size", |d, _| { + if let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) { + (p.width, p.height) = (0.0, 0.0); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[id]); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file read with nothing showing has no outlines, and its box isn't measured instead. + let mut s = session(); + place(&mut s, "hidden.vectorcraft", true); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file whose art is an instance of a symbol of its own: the instance's art is measured. + let src = dir.file("symbol.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let art = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 40.0)), look); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(art) }); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY })).unwrap(); + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(&mut s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + let b = weed_box(&g).unwrap(); + assert!(b.x0 >= 160.0 - 1e-6 && b.x1 <= 240.0 + 1e-6 && b.width() < 41.0, "the bar, not the whole box: {b:?}"); +} + +#[test] +fn a_placed_file_that_cant_be_read_is_measured_by_its_box_as_turned() { + let mut s = session(); + let p = vectorcraft_doc::PlacedDocument { + link: vectorcraft_doc::LinkInfo::new("/no/such/file.pdf"), + key: "missing".into(), + bounding: false, + page_box: Default::default(), + width: 100.0, + height: 100.0, + xf: diamond_xf(), + placement: Default::default(), + }; + insert(&mut s, |id| Node::new(id, NodeKind::PlacedDocument(Box::new(p)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_symbol_instance_is_measured_by_its_art() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 140.0, 100.0, 20.0, 40.0); + select(&mut s, &[a, b]); + let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap()); + select(&mut s, &[inst]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The line runs between the two rectangles too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 140.0), (160.0, 170.0)]); + // An instance of a symbol the document doesn't have draws nothing, and has no outlines. + s.edit("lose the symbol", |d, _| { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(inst).map(|n| &mut n.kind) { + *symbol = "Missing".into(); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[inst]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + select(&mut s, &[inst, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn a_blend_is_measured_by_the_art_it_draws() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 20.0, 40.0); + let blend = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap()); + select(&mut s, &[blend]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The step halfway between the two rectangles stops the line too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 150.0), (170.0, 200.0), (220.0, 230.0)]); +} + +#[test] +fn the_group_goes_on_top_of_the_current_layer_and_hidden_layers_are_left_out() { + let mut s = session(); + let first = s.doc().unwrap().doc.layers[0].id; + let hidden = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap()); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let below = rect(&mut s, 400.0, 400.0, 20.0, 20.0); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(first) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[hidden]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "art in a hidden layer doesn't show"); + select(&mut s, &[hidden, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(g.id), Some(art)); + assert_eq!(d.node(art).unwrap().children().unwrap().last().map(|c| c.id), Some(g.id), "on top"); + // A hidden object selected on its own shows nothing. + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(below) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[below]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "a hidden object doesn't show"); +} + +#[test] +fn the_group_goes_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let g = clip_group(&mut s, shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The clipping path would hide the lines and the box around the art inside it. + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(w.id), Some(layer)); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // A plain group edited in isolation inside a clip group: the group goes outside the clip group. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (inner, art) = s + .edit("clip group around a group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)), Appearance::default()); + let art_id = art.id; + let inner = Node::group(d.alloc_id(), vec![Arc::new(art)]); + let inner_id = inner.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(inner)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + d.insert(parent, usize::MAX, g)?; + Ok((inner_id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": inner.0})).unwrap(); + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(s.doc().unwrap().doc.parent_of(w.id), parent); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); +} + +/// Adds one path of `count` open vertical subpaths 2 pt apart, from (100, 100) down to y = 200. +fn bars(s: &mut Session, count: u32) -> NodeId { + let mut p = BezPath::new(); + for i in 0..count { + let x = 100.0 + f64::from(i) * 2.0; + p.move_to((x, 100.0)); + p.line_to((x, 200.0)); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())) +} + +#[test] +fn more_than_the_most_pieces_of_line_is_refused() { + // 1,000 lines each cut into 100 pieces by 101 bars: as many as one run adds. + let mut s = session(); + bars(&mut s, 101); + let r = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap(); + assert_eq!(r["lines"], json!(super::MAX_PIECES)); + // One bar more makes 1,000 pieces more. + let mut s = session(); + bars(&mut s, 102); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("more than 100000 pieces"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +#[test] +fn art_with_more_than_the_most_outline_points_is_refused() { + // An open zigzag of `points` points: one subpath, measured by its points. + let zigzag = |s: &mut Session, points: u32| { + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..points { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + }; + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32); + s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap(); + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32 + 1); + let e = s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline points"), "{e}"); +} + +#[test] +fn bad_params_and_an_empty_selection_are_refused() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + for p in [ + json!({"margin": -1}), + json!({"gap": "x"}), + json!({"count": 1.5}), + json!({"count": 1001}), + json!({"count": 2, "spacing": 10}), + json!({"spacing": 0}), + json!({"direction": "diagonal"}), + json!({"box": "yes"}), + json!({"weight": 0}), + json!({"swatch": "no such swatch"}), + json!({"swatch": 3}), + json!({"box": false, "count": 0}), + ] { + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing added for {p}"); + } + // The same art with good params is weeded; with nothing selected it is refused. + s.execute("addon.cutting.weed", &json!({"count": 2, "gap": "1 mm"})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err()); +} diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index b2a9d74f83..bfe3d2f4ef 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -20,6 +20,7 @@ use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; +use super::super::paint::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -39,7 +40,7 @@ pub fn specs() -> Vec { "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background )] @@ -182,8 +183,14 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De // Every image is checked before any is decoded. let mut images = vec![]; for id in ids { - let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let Some(n) = st.doc.node(id) else { continue }; + let NodeKind::Image(im) = &n.kind else { continue }; let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The result has an alpha channel, which a grayscale image colorized with a process color + // can't take. + if gray_colorized_with_process(&st.doc, n).is_some() { + return Err(bad(C, PROCESS_ON_TRANSPARENCY)); + } // The preview stands in for a file that hasn't been read, or that is kept at its preview. if im.link.is_some() && blob.is_proxy() { return Err(bad(C, "the linked image shows its preview: embed or relink it first")); diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs index df27180e82..d57f58046f 100644 --- a/crates/engine/src/cmd/addons/image/tests.rs +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -1,8 +1,9 @@ use std::io::Cursor; use std::sync::Arc; -use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use image::{DynamicImage, ImageBuffer, Luma, LumaA, Rgb}; use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; @@ -333,6 +334,54 @@ fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); } +/// A process color can't colorize a grayscale image with transparency, so a gray image colorized +/// with one keeps its pixels. Black, white and spot colors can, so those images are cleared. +#[test] +fn a_gray_image_colorized_with_a_process_color_is_refused() { + let gray = || { + png(DynamicImage::ImageLuma8(ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])))) + }; + let (mut s, id) = open(&gray()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image"); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "the document and its undo steps are unchanged"); + // Another image that already has an alpha channel and a process colorize color, as an opened + // file can hold, neither lets this one through nor stops a run on an image that can be cleared. + let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(2, 2, LumaA([128u8, 100]))))); + let other = place_linked(&mut s, "held", held, (2, 2)); + s.edit("Embed and colorize", |d, _| { + let n = d.node_mut(other).unwrap(); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + if let NodeKind::Image(im) = &mut n.kind { + im.link = None; + } + Ok(()) + }) + .unwrap(); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({"ids": [id.0]})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "with another image refused already, nothing changes"); + s.execute("paint.setFill", &json!({"color": "#000000", "ids": [id.0]})).unwrap(); + assert_eq!(s.execute(C, &json!({"ids": [id.0]})).unwrap()["changed"], 1, "an image colorized with black is cleared beside the other"); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the image stays colorized"); + for fill in [json!({"color": "#000000"}), json!({"color": "#ffffff"}), json!({"swatch": "Ink"})] { + let (mut s, id) = open(&gray()); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} colorizes the image"); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "{fill}: the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} still colorizes the image"); + } +} + /// The ICC profile the colors are in stays with them. #[test] fn the_color_profile_is_kept() { diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 537e6bfe75..10a00b1b45 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result { let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; - let value = from + (to - from) * amount(shape, i, n, random); + // Rounding can carry a value a hair past the range's ends. + let value = (from + (to - from) * amount(shape, i, n, random)).clamp(lo, hi); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; while ends.get(run).is_some_and(|&e| e <= at) { diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs new file mode 100644 index 0000000000..0aeda59731 --- /dev/null +++ b/crates/engine/src/cmd/arrowheads.rs @@ -0,0 +1,453 @@ +//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native +//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols +//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's +//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the +//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times +//! the head's weight. A head covers what its art paints: the visible fills, and the visible +//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with +//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 % +//! opacity is left out, and other opacities, blend modes and opacity masks do not change the +//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art +//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath}; +use vectorcraft_pathops::BoolOp; + +use super::{CommandSpec, Result, Session, always, bad, cmd}; + +/// The arrowheads file's name, in the folder of the preferences. +pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; +/// The largest arrowheads file read, and the most its document or its images may unpack to when +/// compressed. +pub const MAX_FILE_BYTES: u64 = 64 << 20; +/// The most heads read from the file; symbols past it are skipped. +pub const MAX_HEADS: usize = 256; +/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped. +pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "stroke.arrowheads.list", + "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", + always, + list + ), + cmd!( + "stroke.arrowheads.edit", + "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}", + has_file, + edit + ), + cmd!( + "stroke.arrowheads.reload", + "Reload Arrowheads", + [], + None, + "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}", + has_file, + reload + ), + ] +} + +/// The custom arrowheads read from the arrowheads file. +#[derive(Default)] +pub struct Arrowheads { + path: Option, + heads: Vec>, + skipped: Vec<(String, String)>, + error: Option, +} + +impl Arrowheads { + /// The arrowheads file's path; `None` without one (the web, runs without preferences). + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + /// The heads, in the order of the file's symbols. + pub fn heads(&self) -> &[Arc] { + &self.heads + } + /// Head `name`. + pub fn get(&self, name: &str) -> Option> { + self.heads.iter().find(|h| h.name == name).cloned() + } + fn set(&mut self, read: Read) { + (self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped); + self.error = None; + } +} + +/// The heads a document holds, and the symbols that are not heads with the reason. +#[derive(Debug, Default)] +pub struct Read { + pub heads: Vec, + pub skipped: Vec<(String, String)>, +} + +/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs). +pub fn heads_of(doc: &Document) -> Read { + let mut d = doc.clone(); + let brushes = vectorcraft_brush::library(&d); + let mut out = Read::default(); + for (i, sym) in doc.symbols.iter().enumerate() { + if i >= MAX_HEADS { + out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read"))); + continue; + } + if out.heads.iter().any(|h| h.name == sym.name) { + out.skipped.push((sym.name.clone(), "another symbol has this name".into())); + continue; + } + match head(&mut d, &brushes, sym) { + Ok(h) => out.heads.push(h), + Err(why) => out.skipped.push((sym.name.clone(), why.into())), + } + } + out +} + +/// Symbol `sym` of `d` as a head. +fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { + const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + const UNITED: &str = "its shapes could not be combined into one"; + if art_anchors(&sym.art) > MAX_ART_ANCHORS { + return Err(TOO_DETAILED); + } + let art = baked_art(d, sym); + let mut shapes = Shapes::default(); + painted(d, brushes, &art, &mut shapes); + if shapes.too_detailed { + return Err(TOO_DETAILED); + } + if shapes.failed { + return Err(UNITED); + } + let area = shapes.united().ok_or(UNITED)?; + let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; + let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); + // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. + let k = 4.0 / b.height(); + let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]); + let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local)); + if h.path.subpaths.is_empty() { + return Err(TOO_DETAILED); + } + Ok(h) +} + +/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its +/// members' included. +fn art_anchors(n: &Node) -> usize { + let own = match &n.kind { + NodeKind::Mesh(m) => mesh_anchors(m), + _ => n.path_data().map_or(0, PathData::anchor_count), + }; + n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c)))) +} + +/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills', +/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`. +fn baked_art(d: &Document, sym: &Symbol) -> Node { + // A plain new layer, so the file's own layers' effects stay out of the art. + let mut tmp = d.clone(); + tmp.layers = Document::new(1.0, 1.0).layers; + let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() }; + *children = vec![sym.art.clone()]; + let baked = vectorcraft_render::effects::bake_document(&tmp); + baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone()) +} + +/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all. +#[derive(Default)] +struct Shapes { + list: Vec<(PathData, FillRule)>, + anchors: usize, + /// More anchors were asked for than fit; the shapes past the limit were not made. + too_detailed: bool, + /// A clip group's art could not be combined or clipped, so a shape it paints is missing. + failed: bool, +} + +impl Shapes { + /// Whether `anchors` more fit; when they do not, the shapes are too detailed. + fn room(&mut self, anchors: usize) -> bool { + self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS; + !self.too_detailed + } + fn push(&mut self, shape: (PathData, FillRule)) { + if self.room(shape.0.anchor_count()) { + self.anchors += shape.0.anchor_count(); + self.list.push(shape); + } + } + /// The shapes united, or `None` when one of them could not be (the union would miss it). + fn united(&self) -> Option { + let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); + vectorcraft_pathops::try_unite_all(&refs).ok() + } + /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. + fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { + if inside.too_detailed || !self.room(inside.anchors) { + self.too_detailed = true; + return; + } + self.failed |= inside.failed; + let clip = |art: PathData| { + vectorcraft_pathops::try_boolean(&art, FillRule::NonZero, outline, rule, BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION).ok() + }; + let Some(clipped) = inside.united().and_then(clip) else { + self.failed = true; + return; + }; + if !clipped.subpaths.is_empty() { + self.push((clipped, FillRule::NonZero)); + } + } +} + +/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and +/// their strokes' outlines, its groups' members included (baking has already made a group's own +/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip +/// group's clipped art is clipped to +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas; a group used as +/// the clipping path paints nothing ([`clip_paint`]). Hidden or fully +/// transparent art is left out. +fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + fills(n, out); + // The outline holds a copy of the path for each fill too, which adds nothing new. + if let Some(o) = super::pathops::outline_strokes(d, brushes, n) { + fills_in(&o, out); + } + } + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + clip_paint(clip).iter().for_each(|part| painted(d, brushes, part, out)); + // Nothing to clip by hides the clipped art, as on the canvas. + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| painted(d, brushes, c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)), + NodeKind::Mesh(_) => mesh_area(n, out), + _ => {} + } +} + +/// What clipping path `clip` paints ([`Node::clip_paint`]): its fills and its strokes, each as a +/// copy of it keeping only those. A group or another object that does not paint as a clipping +/// path gives neither. +fn clip_paint(clip: &Node) -> Vec { + let paint = clip.clip_paint(); + [paint.fill, paint.stroke].into_iter().flatten().collect() +} + +/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its +/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or +/// across it is outlined as gradient meshes clipped to the stroke's outline. +fn fills_in(n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Mesh(_) => mesh_area(n, out), + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + clip_paint(clip).iter().for_each(|part| fills_in(part, out)); + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| fills_in(c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + _ => match n.children() { + Some(children) => children.iter().for_each(|c| fills_in(c, out)), + None => fills(n, out), + }, + } +} + +/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side, +/// when no point of it is fully transparent, else four by four quads of four anchors a patch. +fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize { + let (rows, cols) = (m.rows as usize, m.cols as usize); + if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) } +} + +fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool { + m.points.iter().all(|p| p.opacity > 0.0) +} + +/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else +/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose +/// pieces do not fit in `out` makes the shapes too detailed. +fn mesh_area(n: &Node, out: &mut Shapes) { + let NodeKind::Mesh(m) = &n.kind else { return }; + if !out.room(mesh_anchors(m)) { + return; + } + if opaque(m) { + let outline = m.outline(); + if !outline.subpaths.is_empty() { + out.push((outline, FillRule::NonZero)); + } + return; + } + for q in m.quads(4) { + if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) { + out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero)); + } + } +} + +/// The area path `n` fills, when a shown fill paints it. +fn fills(n: &Node, out: &mut Shapes) { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none()); + if n.appearance.items.iter().any(paints) + && let Some(shape) = super::pathops::node_path(n) + { + out.push(shape); + } +} + +/// The document a missing arrowheads file starts as: one example head, a solid triangle. +pub fn starter() -> Document { + let mut doc = Document::new(612.0, 792.0); + doc.title = "Arrowheads".into(); + let name = "Example Arrowhead"; + let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true); + let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)); + let art = doc.reid(&art); + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); + // The tip is the right edge's middle. + doc.set_symbol_registration(name, SymbolRegistration(5)); + let id = doc.alloc_id(); + let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) }); + let layer = doc.layers.first().map(|l| l.id); + let _ = doc.insert(layer, 0, instance); + doc +} + +impl Session { + /// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it + /// is missing; an unreadable file is reported by `stroke.arrowheads.list`). + pub fn attach_arrowheads_file(&mut self, path: String) { + self.arrowheads.path = Some(path); + self.reload_arrowheads(); + } + + /// Read the arrowheads file again. + fn reload_arrowheads(&mut self) { + let Some(path) = self.arrowheads.path.clone() else { return }; + match read(&path) { + Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)), + Ok(None) => self.arrowheads.set(Read::default()), + Err(e) => { + self.arrowheads.set(Read::default()); + self.arrowheads.error = Some(e); + } + } + } + + /// A document was saved to `path`: when that is the arrowheads file, its heads are read back + /// from what was written. + pub(crate) fn arrowheads_saved(&mut self, path: &str) { + if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { + self.reload_arrowheads(); + } + } +} + +fn same_file(a: &str, b: &str) -> bool { + let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p)); + std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b) +} + +/// The document in the arrowheads file at `path`; `None` when there is no file. +fn read(path: &str) -> std::result::Result, String> { + let size = match std::fs::metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{path}: {e}")), + Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")), + Ok(m) => m.len(), + }; + if size > MAX_FILE_BYTES { + return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); + } + let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; + // A compressed file's document, and its images, may unpack to no more than an uncompressed + // file may hold. + let parse = || vectorcraft_format::load_file_capped(&bytes, MAX_FILE_BYTES).map(|f| f.doc).map_err(|e| format!("{path}: {e}")); + crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) +} + +fn has_file(s: &Session) -> std::result::Result<(), String> { + if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) } +} + +fn skipped(s: &Session) -> Vec { + s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect() +} + +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})) +} + +fn reload(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.reload"; + s.reload_arrowheads(); + if let Some(e) = &s.arrowheads.error { + return Err(bad(C, e.clone())); + } + Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)})) +} + +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"))?; + 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())))?; + } + // Made only when no file is there, also when another run makes one at the same moment. + let bytes = vectorcraft_format::save_file(&starter()); + let created = match crate::file_access::unconfined(|| super::fileio::write_new_file(&path, &bytes)) { + Ok(()) => true, + Err(_) if std::path::Path::new(&path).exists() => false, + Err(e) => return Err(bad(C, e.to_string())), + }; + if created { + s.reload_arrowheads(); + } + // Already open: that document comes to the front. + let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path))); + match open { + Some(i) => { + s.set_active(i); + } + None => { + crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?; + } + } + Ok(json!({"path": path, "created": created})) +} diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 988aa89572..16ab1c76d4 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -170,6 +170,7 @@ pub struct BuiltinBrushLibrary { /// The built-in brush libraries, in menu order: original brushes generated in code. pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, + BuiltinBrushLibrary { id: "ink-and-wash", name: "Ink and Wash", make: ink_and_wash }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, @@ -268,6 +269,78 @@ fn art_ribbons() -> BrushLibrary { built("Art Ribbons", doc, brushes) } +/// A band between the edges y = `top(x)` and y = `bottom(x)` for x in `from`..`to`, in `n` steps. +fn between(from: f64, to: f64, n: usize, top: impl Fn(f64) -> f64, bottom: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, top(x))).collect(); + pts.extend(xs.iter().rev().map(|&x| Point::new(x, bottom(x)))); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// Path `p` filled with gray `hex` at `opacity`: one layer of a wash. +fn wash(p: PathData, hex: &str, opacity: f32) -> Node { + let mut n = filled(p, Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK))); + n.opacity = opacity; + n +} + +/// How much of a wash's width is left at x: rounded ends, full width from 8 pt in. +fn wash_ends(x: f64) -> f64 { + let ends = ((x / 8.0).min(1.0) * ((100.0 - x) / 8.0).min(1.0)).max(0.0); + 0.4 + 0.6 * ends.sqrt() +} + +/// Ink and Wash: Tapered Ink, broad near its start and thinning to a fine tip; Brush Pen, a +/// rounded nib whose size follows the pen pressure; and two washes of translucent grays, Soft Wash +/// with faint edges and Pooling Wash with a darker rim and two light breaks. Black and grays take +/// the stroke color as tints of it. +fn ink_and_wash() -> BrushLibrary { + let tapered = { + let half = |x: f64| 3.0 * (x / 22.0).min(1.0).powf(0.6) * ((100.0 - x) / 78.0).min(1.0).powf(1.4); + black(between(0.0, 100.0, 48, |x| -0.85 * half(x), |x| 1.15 * half(x))) + }; + let pen = Brush { + name: "Brush Pen".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 40.0, + roundness: 75.0, + size: 5.0, + variation: [0.0, 0.0, 2.5], + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]), + }), + }; + let soft = { + let top = |x: f64| -(4.5 + 0.5 * (x * 0.21).sin() + 0.3 * (x * 0.57 + 1.3).sin()) * wash_ends(x); + let bottom = |x: f64| (4.5 + 0.45 * (x * 0.26 + 2.1).sin() + 0.3 * (x * 0.69 + 0.4).sin()) * wash_ends(x); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#404040", 0.4), + wash(between(2.0, 98.0, 32, |x| top(x) - 0.7 - 0.3 * (x * 0.43).sin().abs(), |x| top(x) + 0.5), "#404040", 0.2), + wash(between(3.0, 97.0, 32, |x| bottom(x) - 0.5, |x| bottom(x) + 0.6 + 0.35 * (x * 0.37 + 1.0).sin().abs()), "#404040", 0.18), + wash(shapes::ellipse(Rect::new(21.0, -2.4, 39.0, 0.8)), "#e6e6e6", 0.22), + wash(shapes::ellipse(Rect::new(61.0, -0.3, 75.0, 2.3)), "#e6e6e6", 0.18), + ]) + }; + let pooling = { + let top = |x: f64| -(4.0 + 0.4 * (x * 0.18 + 0.7).sin() + 0.2 * (x * 0.53).sin()) * wash_ends(x); + let bottom = |x: f64| (4.0 + 0.35 * (x * 0.23 + 1.9).sin() + 0.25 * (x * 0.61 + 0.2).sin()) * wash_ends(x); + let rim = |x: f64, k: f64, phase: f64| 0.35 + k * (0.5 + 0.5 * (x * 0.29 + phase).sin()); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#4d4d4d", 0.35), + wash(between(1.0, 99.0, 40, top, |x| top(x) + rim(x, 0.45, 0.0)), "#262626", 0.55), + wash(between(1.0, 99.0, 40, |x| bottom(x) - rim(x, 0.5, 1.1), bottom), "#262626", 0.55), + wash(polygon(&[(34.0, -2.6), (39.0, -2.8), (36.0, 2.7), (31.0, 2.5)]), "#e6e6e6", 0.2), + wash(polygon(&[(70.0, -2.4), (74.0, -2.5), (72.0, 2.6), (68.0, 2.4)]), "#e6e6e6", 0.18), + ]) + }; + let brushes = vec![ + art("Tapered Ink", tapered, Colorization::Tints), + pen, + art("Soft Wash", soft, Colorization::Tints), + art("Pooling Wash", pooling, Colorization::Tints), + ]; + built("Ink and Wash", Document::new(100.0, 100.0), brushes) +} + fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) } } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index fb8af2b11e..76b4e1a715 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure|tilt|bearing|rotation, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest; tilt: width with the pen lying flat to width_max upright; bearing and rotation: width at −180° to width_max at 180°; half way on a stroke drawn without that input), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 53e1b655a1..94fe55c119 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 9463dd8cf1..e98dbd5619 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result { if text.trim().is_empty() { return Err(bad(C, "the text is empty")); } - let style = super::super::create::new_type_style(s, &Value::Null); + let style = super::super::create::new_type_style(s, &Value::Null, C)?; let system_layout = s.doc()?.doc.setup.system_layout; let layout = |t: &mut TextObject| { t.system_layout = system_layout; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index da7dc42ece..1046c4a948 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { let remembered = s.store_profile(&info.name, &bytes); // A new file of a working space: the active settings still hold the old one. let working = cms::active_settings(); - if working.rgb == info.name || working.cmyk == info.name { + let is_working = working.rgb == info.name || working.cmyk == info.name; + if is_working { cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?; + } + // A new file of a working space, or of the CMYK profile View → Proof Setup targets: redraw. + if is_working || proof_targets(&info.name) { touch_all(s); } Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) @@ -233,10 +237,17 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no profile named `{name}`"))); } s.unstore_profile(&info.name); - touch_all(s); + if proof_targets(&info.name) { + touch_all(s); + } Ok(json!({"removed": info.name})) } +/// Whether View → Proof Setup targets the CMYK profile `name`. +fn proof_targets(name: &str) -> bool { + matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name) +} + #[cfg(target_arch = "wasm32")] fn load_profile(_: &mut Session, _: &Value) -> Result { Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser")) @@ -274,14 +285,17 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { Ok(()) })?; let mut st = cms::active_settings(); + let before = st.clone(); if let Some(r) = &rgb { st.rgb = r.clone(); } if let Some(c) = &cmyk { st.cmyk = c.clone(); } - cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; - touch_all(s); + if st != before { + cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; + touch_all(s); + } } Ok(json!({"rgb": rgb, "cmyk": cmyk})) } @@ -760,17 +774,33 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { if let Some(on) = on_param(p, "proof", C)? { v.proof_colors = on; } - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } +/// Make `v` the proof view and redraw every document. When `v` equals the current view, the view +/// and the revisions stay as they are; Separations Preview's visible plates are compared as a set. +fn store_view(s: &mut Session, v: proof::ProofView) { + let shown = |v: &proof::ProofView| proof::ProofView { + separations: v.separations.as_ref().map(|vis| { + let mut vis = vis.clone(); + vis.sort(); + vis.dedup(); + vis + }), + ..v.clone() + }; + if shown(&v) != shown(&proof::view()) { + proof::set_view(v); + touch_all(s); + } +} + fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { let on = on_param(p, "on", cmd)?; let mut v = proof::view(); f(&mut v, on); - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } @@ -802,7 +832,15 @@ fn separations(s: &mut Session, p: &Value) -> Result { match p.get("plates") { None | Some(Value::Null) => {} Some(Value::Array(a)) if a.iter().all(Value::is_string) => { - v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + let mut vis: Vec = Vec::new(); + for n in a.iter().filter_map(Value::as_str) { + let n = check(n)?; + // A plate named twice is shown once. + if !vis.contains(&n) { + vis.push(n); + } + } + v.separations = Some(vis); } Some(_) => { let key = "plates"; @@ -821,8 +859,7 @@ fn separations(s: &mut Session, p: &Value) -> Result { vis.push(n); } } - proof::set_view(v); - touch_all(s); + store_view(s, v); Ok(plates_json(s)) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 37473cc59f..ebef729fd3 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt 0.1..1296 (outside it: an error, and no type is made), font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -739,7 +739,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { check_numbers(a, &["width", "height"], "text.create")?; } let text = str_param(p, "text").unwrap_or(""); - let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p, "text.create")?); // Turned about the point (the first baseline's start, an area's top-left corner): type set // along a Constrain Angle. let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 }; @@ -848,11 +848,11 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB /// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults, /// filled with the current fill unless that is None or white (black then), in the document's -/// language. -pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { +/// language. A `size` outside [`super::typecmd::TYPE_SIZES`] is an error. +pub(crate) fn new_type_style(s: &Session, p: &Value, cmd: &str) -> Result { let mut style = CharStyle::default(); if let Some(sz) = p.get("size").and_then(Value::as_f64) { - style.size = sz.clamp(0.1, 1296.0); + style.size = super::typecmd::given_type_size(sz, cmd)?; } if let Some(f) = str_param(p, "font") { style.font_family = f.to_string(); @@ -873,5 +873,5 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten(); let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language)); style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string); - style + Ok(style) } diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..7cb2ff138d 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -7,8 +7,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; -use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; +use super::fileio::{Note, default_name, encode_with_notes, isolated, note_texts, write_file}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets}; pub fn specs() -> Vec { vec![ @@ -21,13 +21,13 @@ pub fn specs() -> Vec { has_doc, selection ), - cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate), + cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate), cmd!( "css.export", "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), @@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result { Ok(result(&sheet(s, p, ids, "css.selection")?)) } +/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does +/// not use them: a malformed one is an error, as it is for the selection. +fn unused_ids(p: &Value) -> Result<()> { + named_ids(p).map(|_| ()) +} + fn generate(s: &mut Session, p: &Value) -> Result { + unused_ids(p)?; let ids = everything(s)?; Ok(result(&sheet(s, p, ids, "css.generate")?)) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a +/// string, null included, is an error: "`{key}` must be a string". +fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result> { + p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() +} + fn export(s: &mut Session, p: &Value) -> Result { const C: &str = "css.export"; - let ids = match str_param(p, "scope").unwrap_or("selection") { + let scope = given_text(p, "scope", C)?; + let path = given_text(p, "path", C)?; + let ids = match scope.unwrap_or("selection") { "selection" => targets(s, p)?, - "all" => everything(s)?, + "all" => { + unused_ids(p)?; + everything(s)? + } other => return Err(bad(C, format!("scope must be selection or all (got {other})"))), }; let sheet = sheet(s, p, ids, C)?; @@ -100,22 +118,24 @@ fn export(s: &mut Session, p: &Value) -> Result { } let css = format!("{}\n", sheet.text()); let doc = &s.doc()?.doc; - // Encode the pictures of rasterized art, and add each of their warnings once. + // Encode the pictures of rasterized art, and add each of their warnings once. What CSS can't + // describe is a loss. let mut images = Vec::new(); - let mut warnings = sheet.warnings.clone(); + let mut notes: Vec = sheet.warnings.iter().cloned().map(Note::loss).collect(); for r in &sheet.rules { let Some(name) = &r.image else { continue }; let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?; - let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?; + let (png, given) = encode_with_notes(&d, "png", &json!({}))?; images.push((name, png)); - for w in notes { - if !warnings.contains(&w) { - warnings.push(w); + for n in given { + if !notes.iter().any(|x| x.text == n.text) { + notes.push(n); } } } + let warnings = note_texts(¬es); let n = sheet.rules.len(); - match str_param(p, "path") { + match path { Some(path) => { write_file(path, css.as_bytes())?; let folder = std::path::Path::new(path).parent(); diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index e1b88d5d9f..44ac08d7ad 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> ArrowStyle::Tick => Some(Arrowhead::Slash), ArrowStyle::None => None, }; - st.start_arrow = head; + st.start_arrow = head.clone(); st.end_arrow = head; st.arrow_scale = (o.arrow_scale, o.arrow_scale); st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index ff1fbe3591..ac79d0acc7 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -40,7 +40,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ("objects", "Objects"), ("graphicStyles", "Graphic Styles"), ("brushes", "Brushes"), - ("spotColors", "Spot Colors"), + ("spotColors", "Spot Color Objects"), ("patterns", "Pattern Objects"), ("gradients", "Gradient Objects"), ("symbols", "Symbols"), @@ -52,7 +52,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ]; /// The object counts of `document.info` and their labels. -const OBJECT_LABELS: [(&str, &str); 18] = [ +const OBJECT_LABELS: [(&str, &str); 19] = [ ("paths", "Paths"), ("compoundPaths", "Compound Paths"), ("groups", "Groups"), @@ -63,6 +63,7 @@ const OBJECT_LABELS: [(&str, &str); 18] = [ ("symbolInstances", "Symbol Instances"), ("gradients", "Gradient Objects"), ("patterns", "Pattern Objects"), + ("spots", "Spot Color Objects"), ("meshes", "Gradient Meshes"), ("blends", "Blends"), ("envelopes", "Envelopes"), @@ -130,15 +131,18 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a pattern and with a gradient. +/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the +/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a +/// pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], + brushes: &[(String, String)], repeats: &[(String, String)], + spot_objects: &[(String, String)], pattern_objects: &[(String, String)], gradient_objects: &[(String, String)], category: Option<&str>, @@ -182,8 +186,8 @@ fn sections( rows } "graphicStyles" => names("graphicStyleNames"), - "brushes" => names("brushNames"), - "spotColors" => names("spotColors"), + "brushes" => brushes.to_vec(), + "spotColors" => spot_objects.to_vec(), "patterns" => pattern_objects.to_vec(), "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), @@ -273,15 +277,50 @@ fn gradient_label(p: &Paint) -> Option { } } -/// The type object `n` holds and the runs its layout draws, when it uses a pattern or a gradient: -/// the runs with a glyph outline (list markers and ruby included) or an underline or strikethrough -/// bar. Spaces and line breaks draw no outline. A type object's characters carry their own fill and -/// stroke, and the type object's own fills and strokes paint on its characters, so only those runs -/// paint. The type is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the -/// renderer lays it out. -fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { +/// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own +/// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as +/// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not +/// one. +fn spot_swatches(d: &Document) -> HashSet<&str> { + let mut first: BTreeMap<&str, bool> = BTreeMap::new(); + for s in d.swatches_iter() { + first.entry(s.name.as_str()).or_insert(s.spot && s.paint.color().is_some()); + } + first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect() +} + +/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is +/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A +/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws +/// nothing, and a color that only looks like a spot color is not linked to one. +fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { + let mut out: Vec = vec![]; + let mut add = |swatch: &Option, tint: f32| { + if let Some(name) = swatch + && tint > 0.0 + && spots.contains(name.as_str()) + && !out.contains(name) + { + out.push(name.clone()); + } + }; + match p { + Paint::Solid { swatch, tint, .. } => add(swatch, *tint), + Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)), + _ => {} + } + out +} + +/// The type object `n` holds and the runs its layout draws, when it uses a spot color, a pattern or +/// a gradient (`spots`: [`spot_swatches`]): the runs with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar. Spaces and line breaks draw no outline. A type +/// object's characters carry their own fill and stroke, and the type object's own fills and strokes +/// paint on its characters, so only those runs paint. The type is laid out with its inline +/// graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { let NodeKind::Text(t) = &n.kind else { return None }; - let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)); + let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)) || !spot_labels(spots, p).is_empty(); let own = n.appearance.items.iter().any(|i| match i { vectorcraft_doc::AppearanceItem::Fill(f) => is_listed(&f.paint), vectorcraft_doc::AppearanceItem::Stroke(s) => is_listed(&s.paint), @@ -289,51 +328,103 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) { return None; } + Some((t, drawn_runs(d, t))) +} + +/// The runs of type `t` that its layout draws: those with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar, laid out as the renderer lays it out. +fn drawn_runs(d: &Document, t: &vectorcraft_doc::TextObject) -> BTreeSet { let db = vectorcraft_text::FontDb::global(); let resolved = d.inline_resolved(t); let lay = vectorcraft_text::layout(db, &resolved); let mut painted: BTreeSet = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); - Some((t, painted)) + painted } -/// The patterns or gradients (`label`: [`pattern_label`] or [`gradient_label`]) that `n`'s own -/// fills and strokes paint, each once. A hidden fill or stroke and a stroke with no width paint -/// nothing, and neither does any fill or stroke of a guide (drawn in the guide color), of a compound -/// path's member (`members`: the compound path's own fills and strokes paint it) or of type whose -/// layout draws nothing (`painted_runs`). An image's fills and strokes paint only where an effect -/// gives them a shape. +/// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its +/// gradient. +type PaintNames<'a> = dyn Fn(&Paint) -> Vec + 'a; + +/// The spot colors, patterns or gradients (`label`: [`spot_labels`], [`pattern_label`] or +/// [`gradient_label`], each paint's names) that `n`'s own fills and strokes paint, each once. A +/// hidden fill or stroke, a fill or stroke at an opacity of 0 and a stroke with no width paint +/// nothing, and neither does any fill or +/// stroke of a guide (drawn in the guide color), of a compound path's member (`members`: the +/// compound path's own fills and strokes paint it) or of type whose layout draws nothing +/// (`painted_runs`). An image's fills and strokes paint only where an effect gives them a shape. fn own_paints( d: &Document, n: &Node, members: &HashSet<*const Node>, painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, - label: fn(&Paint) -> Option, + label: &PaintNames<'_>, ) -> Vec { if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) || painted_runs.is_some_and(|(_, r)| r.is_empty()) - || !n.appearance.items.iter().any(|i| label(i.paint()).is_some()) + || n.appearance.items.iter().all(|i| label(i.paint()).is_empty()) { return vec![]; } - // The patterns or gradients an image's shaped fills and strokes paint (their effects can - // transform the paint, so they are matched by label). + // The names an image's shaped fills and strokes paint (their effects can transform the paint, + // so they are matched by name). let shaped: Option> = matches!(n.kind, NodeKind::Image(_)) - .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().filter_map(|(_, _, item)| label(item.paint())).collect()); + .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect()); let mut out: Vec = vec![]; for item in &n.appearance.items { - let Some(name) = label(item.paint()) else { continue }; - let paints = item.visible() - && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) - && shaped.as_ref().is_none_or(|s| s.contains(&name)); - if paints && !out.contains(&name) { - out.push(name); + if !item.visible() || item.opacity() <= 0.0 || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { + continue; + } + for name in label(item.paint()) { + if shaped.as_ref().is_none_or(|s| s.contains(&name)) && !out.contains(&name) { + out.push(name); + } } } out } +/// [`pattern_label`] or [`gradient_label`] as the names [`own_paints`] takes. +fn one_label(label: fn(&Paint) -> Option) -> impl Fn(&Paint) -> Vec { + move |p| label(p).into_iter().collect() +} + +/// The brushes `n`'s own strokes draw with, in the order of its appearance. A hidden stroke, a +/// stroke at an opacity of 0, a stroke with no paint and a stroke with no width draw nothing, and neither does any stroke of a +/// guide (drawn in the guide color) or of a compound path's member (`members`: the compound path's +/// own strokes draw it). An image's strokes draw only where an effect gives them a shape, and only +/// paths, compound paths, type and images draw strokes of their own. +fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Vec { + if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) { + return vec![]; + } + let brush = |item: &vectorcraft_doc::AppearanceItem| match item { + vectorcraft_doc::AppearanceItem::Stroke(st) if item.visible() && st.opacity > 0.0 && !st.paint.is_none() && st.width > 0.0 => { + st.brush.clone().filter(|b| !b.is_empty()) + } + _ => None, + }; + if matches!(n.kind, NodeKind::Image(_)) { + return vectorcraft_render::effects::image_paint(Some(d), n).iter().filter_map(|(_, _, item)| brush(item)).collect(); + } + let used: Vec = n.appearance.items.iter().filter_map(brush).collect(); + if used.is_empty() { + return used; + } + // A path or compound path with no segment gives its strokes nothing to follow, and neither does + // type whose layout draws nothing (spaces and line breaks without an underline or strikethrough). + let segments = |pd: &vectorcraft_geom::PathData| pd.subpaths.iter().any(|sp| sp.segment_count() > 0); + let follows = match &n.kind { + NodeKind::Path { .. } => n.path_data().is_some_and(segments), + NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).any(segments), + NodeKind::Text(t) => !drawn_runs(d, t).is_empty(), + // A brush on any other kind of object (a layer, a group, a ruler guide) is not listed. + _ => false, + }; + if follows { used } else { vec![] } +} + fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "document.info"; let category = str_param(p, "category"); @@ -370,10 +461,18 @@ fn info(s: &mut Session, p: &Value) -> Result { }; let mut patterns = BTreeSet::new(); let mut symbols = BTreeSet::new(); + // `spot_objects`, `spot_rows` and `spot` hold the `spotObjects` entries, the rows of the objects filled or stroked with a spot + // color, in walk order, and the spot colors they use. `spot_swatches` names the document's spot colors once, for every paint. + let spot_swatches = spot_swatches(d); + let mut spot_objects: Vec = vec![]; + let mut spot_rows: Vec<(String, String)> = vec![]; let mut spot = BTreeSet::new(); + let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label)); let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; - // `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use. - let mut brushes: Vec = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() }; + // `library` holds the Brushes panel's brushes, and `brushes` the brushes the visited objects' strokes draw with, each once, + // in walk order. + let library = vectorcraft_brush::library(d); + let mut brushes: Vec = vec![]; // `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order. let mut repeats: Vec = vec![]; let mut repeat_rows: Vec<(String, String)> = vec![]; @@ -446,17 +545,11 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - for item in &n.appearance.items { - if let Paint::Solid { swatch: Some(name), .. } = item.paint() - && d.swatch(name).is_some_and(|s| s.spot) - { - spot.insert(name.clone()); - } - } - // The patterns and the gradients this object's fills and strokes paint, each once. - let painted_runs = painted_runs(d, n); - let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), pattern_label); - let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), gradient_label); + // The spot colors, the patterns and the gradients this object's fills and strokes paint, each once. + let painted_runs = painted_runs(d, &spot_swatches, n); + let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label); + let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern); + let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient); // The characters' own paints: an inline graphic draws no outline and does not take its // run's paint (a list marker set in its run does), and a stroke with no width paints // nothing. @@ -464,19 +557,38 @@ fn info(s: &mut Session, p: &Value) -> Result { for run in painted.iter().filter_map(|i| t.runs.get(*i)) { let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); for paint in std::iter::once(&run.style.fill).chain(stroke) { - for (used, label) in - [(&mut used_patterns, pattern_label as fn(&Paint) -> Option), (&mut used_gradients, gradient_label)] - { - if let Some(name) = label(paint) - && !used.contains(&name) - { - used.push(name); + let labels: [(&mut Vec, &PaintNames<'_>); 3] = + [(&mut used_spots, &spot_label), (&mut used_patterns, &pattern), (&mut used_gradients, &gradient)]; + for (used, label) in labels { + for name in label(paint) { + if !used.contains(&name) { + used.push(name); + } } } } } } - // Objects counts the objects that Pattern Objects and Gradient Objects list. + // A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take + // the color, as the renderer draws it. + if let NodeKind::Image(im) = &n.kind + && let Some(colorize) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| b.takes_colorize()) + { + for name in spot_label(colorize) { + if !used_spots.contains(&name) { + used_spots.insert(0, name); + } + } + } + // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. + if !used_spots.is_empty() { + let name = n.display_name(); + spot.extend(used_spots.iter().cloned()); + spot_rows.push((name.clone(), used_spots.join(", "))); + spot_objects.push(json!({ "id": n.id.0, "name": name, "spotColors": used_spots })); + *counts.entry("spots").or_default() += 1; + } if !used_patterns.is_empty() { let name = n.display_name(); patterns.extend(used_patterns.iter().cloned()); @@ -503,14 +615,9 @@ fn info(s: &mut Session, p: &Value) -> Result { { styles.push(&g.name); } - if selection_only { - for item in &n.appearance.items { - if let vectorcraft_doc::AppearanceItem::Stroke(st) = item - && let Some(b) = &st.brush - && !brushes.contains(b) - { - brushes.push(b.clone()); - } + for b in applied_brushes(d, n, &members) { + if !brushes.contains(&b) { + brushes.push(b); } } if let NodeKind::Repeat(spec) = &n.kind { @@ -521,6 +628,17 @@ fn info(s: &mut Session, p: &Value) -> Result { }); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); + // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two + // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. + let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); + let brush_entries: Vec = + brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect(); + let brush_rows: Vec<(String, String)> = brushes + .iter() + .map(|name| { + (name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} brush", k.label()))) + }) + .collect(); let mut out = json!({ "document": document_summary(d), "objects": counts, @@ -529,10 +647,14 @@ fn info(s: &mut Session, p: &Value) -> Result { "images": images, "symbols": symbols, "spotColors": spot, + "spotColorNames": spot, + "spotObjects": spot_objects, "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), "graphicStyleNames": styles, "brushNames": brushes, + "brushes": brush_entries, + "panelBrushNames": library.iter().map(|b| b.name.as_str()).collect::>(), "repeatObjects": repeats, "characterStyles": d.char_styles.len(), "paragraphStyles": d.para_styles.len(), @@ -544,7 +666,7 @@ fn info(s: &mut Session, p: &Value) -> Result { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, &gradient_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -657,38 +779,129 @@ mod tests { assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0); } - /// Document Info › Brushes lists the Brushes panel's brushes, and with Selection Only it lists - /// the brushes the selected objects' strokes use, each once. + /// Document Info › Brushes lists the brushes the art's strokes use, each once with its type, and + /// leaves out the Brushes panel's brushes no art uses (`panelBrushNames` lists those). With + /// Selection Only it lists the brushes the selected objects' strokes use. #[test] fn info_lists_the_brushes() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); let panel: Vec = s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().iter().map(|b| b["name"].clone()).collect(); - let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap(); - assert_eq!(i["brushNames"], json!(panel)); + let brushes = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap(); + // No art uses a brush: the category is empty. + let i = brushes(&mut s, json!({"category": "brushes"})); + assert_eq!((i["brushNames"].clone(), i["brushes"].clone(), i["panelBrushNames"].clone()), (json!([]), json!([]), json!(panel))); assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("brushes"), Some("Brushes"))); - let rows: Vec = panel.iter().map(|n| json!([n, ""])).collect(); - assert_eq!(i["sections"][0]["rows"], json!(rows)); + assert_eq!(i["sections"][0]["rows"], json!([])); let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); let c = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); s.execute("brush.apply", &json!({"ids": [a], "name": "Charcoal"})).unwrap(); - s.execute("brush.apply", &json!({"ids": [b], "name": "Arrow"})).unwrap(); + s.execute("brush.apply", &json!({"ids": [b], "name": "Dots"})).unwrap(); s.execute("brush.apply", &json!({"ids": [c], "name": "Charcoal"})).unwrap(); - let names = - |s: &mut Session| s.execute("document.info", &json!({"selectionOnly": true, "category": "brushes"})).unwrap()["brushNames"].clone(); + // The whole document: the two brushes the art uses, each once, with their types; "3 pt. Round" (a Calligraphic + // brush in the panel) is left out. + let i = brushes(&mut s, json!({"category": "brushes"})); + assert_eq!(i["brushNames"], json!(["Charcoal", "Dots"])); + assert_eq!(i["brushes"], json!([{"name": "Charcoal", "type": "art"}, {"name": "Dots", "type": "scatter"}])); + assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art brush"], ["Dots", "Scatter brush"]])); + assert!(panel.contains(&json!("3 pt. Round")) && i["panelBrushNames"] == json!(panel)); + let text = brushes(&mut s, json!({"category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nBRUSHES\nCharcoal: Art brush\nDots: Scatter brush\n") && !text.contains("3 pt. Round"), "{text}"); + assert!(!text.contains("OBJECTS"), "{text}"); + // Selection Only: the brushes the selected objects' strokes use. + let selected = |s: &mut Session| brushes(s, json!({"selectionOnly": true, "category": "brushes"})); s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); - assert_eq!(names(&mut s), json!(["Charcoal", "Arrow"])); + assert_eq!(selected(&mut s)["brushNames"], json!(["Charcoal", "Dots"])); s.execute("select.set", &json!({"ids": [b]})).unwrap(); - assert_eq!(names(&mut s), json!(["Arrow"])); + let i = selected(&mut s); + assert_eq!( + (i["brushes"].clone(), i["sections"][0]["rows"].clone()), + (json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter brush"]])) + ); + let text = brushes(&mut s, json!({"selectionOnly": true, "category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nBRUSHES\nDots: Scatter brush\n") && !text.contains("Charcoal"), "{text}"); // A stroke without a brush lists none. let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [plain]})).unwrap(); - assert_eq!(names(&mut s), json!([])); - let text = s.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(text.contains(&format!("\nBRUSHES\n{}\n", panel[0].as_str().unwrap())), "{text}"); - assert!(text.contains("\nCharcoal\n") && text.contains("\nArrow\n") && !text.contains("OBJECTS"), "{text}"); + assert_eq!(selected(&mut s)["brushNames"], json!([])); + } + + /// Document Info › Brushes leaves out brushes on strokes that draw nothing (hidden, at an + /// opacity of 0, with no width or paint, a guide's, a compound path member's, a path's with no segment, type's that + /// draws no characters, a group's own), lists a brush the Brushes panel does + /// not hold with no type, and gives a name two panel brushes share the type the renderer uses. + #[test] + fn info_brushes_skip_undrawn_strokes_and_name_missing_brushes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let square = |s: &mut Session, x: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap() + }; + let (hidden, thin, guide, missing) = (square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0)); + let (empty, faint) = (square(&mut s, 80.0), square(&mut s, 200.0)); + s.execute("brush.apply", &json!({"ids": [hidden, thin, guide, missing, empty, faint], "name": "Charcoal"})).unwrap(); + let (p, q) = (square(&mut s, 100.0), square(&mut s, 120.0)); + s.execute("select.set", &json!({"ids": [p, q]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + // A compound path's member: the compound path's own stroke draws it. + let NodeKind::Compound { children, .. } = &mut d.node_mut(NodeId(compound)).unwrap().kind else { panic!("not a compound path") }; + let member = &mut std::sync::Arc::make_mut(&mut children[0]).appearance; + member.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + member.stroke_mut().unwrap().brush = Some("Dots".into()); + d.node_mut(NodeId(hidden)).unwrap().appearance.stroke_mut().unwrap().visible = false; + d.node_mut(NodeId(thin)).unwrap().appearance.stroke_mut().unwrap().width = 0.0; + // A stroke at an opacity of 0 draws nothing. + d.node_mut(NodeId(faint)).unwrap().appearance.stroke_mut().unwrap().opacity = 0.0; + if let NodeKind::Path { guide: g, .. } = &mut d.node_mut(NodeId(guide)).unwrap().kind { + *g = true; + } + // A path with no segment: its stroke follows nothing. + if let NodeKind::Path { path, .. } = &mut d.node_mut(NodeId(empty)).unwrap().kind { + *path = vectorcraft_geom::PathData::default(); + } + d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Gone".into()); + let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap(); + assert_eq!(i["brushes"], json!([{"name": "Gone", "type": null}])); + assert_eq!(i["sections"][0]["rows"], json!([["Gone", "missing from the Brushes panel"]])); + // A stroke with no paint draws nothing. + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None; + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([])); + // Two panel brushes with one name: the type is the one the renderer draws with, the last one's. + let twin = square(&mut s, 140.0); + s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut lib = vectorcraft_brush::library(d); + let kinds: Vec = + ["Charcoal", "Dots"].iter().map(|n| lib.iter().find(|b| b.name == *n).unwrap().kind.clone()).collect(); + lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind })); + vectorcraft_brush::store(d, &lib); + d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into()); + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}])); + // Type: a brushed stroke on drawn characters is listed, one on spaces alone is not. + let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap(); + let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + for (id, name) in [(blank, "Dots"), (word, "Arrow")] { + let ap = &mut d.node_mut(NodeId(id)).unwrap().appearance; + ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + ap.stroke_mut().unwrap().brush = Some(name.into()); + } + let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone(); + assert_eq!(names, json!(["Twin", "Arrow"])); + // A group's own stroke: the group draws its members. + let (m1, m2) = (square(&mut s, 160.0), square(&mut s, 180.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let ap = &mut d.node_mut(NodeId(group)).unwrap().appearance; + ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + ap.stroke_mut().unwrap().brush = Some("Leaves".into()); + let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone(); + assert_eq!(names, json!(["Twin", "Arrow"])); } /// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number @@ -799,6 +1012,11 @@ mod tests { s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap(); s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // Nor does a pattern fill at an opacity of 0. + let clear = square(&mut s, 220.0); + s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [clear], "swatch": "Dots"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [clear], "index": 0, "opacity": 0})).unwrap(); // A type object with no characters paints nothing either. let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap(); @@ -859,7 +1077,7 @@ mod tests { assert_eq!(i["patternNames"], json!(["Dots"])); // Objects counts the objects that Pattern Objects lists. assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]); - s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [d, e, clear, empty, blank, guide, compound, image.0]})).unwrap(); let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]); let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap(); @@ -997,6 +1215,169 @@ mod tests { assert!(!text.contains("Spare"), "{text}"); } + /// Document Info › Spot Color Objects lists each object filled or stroked with a spot color once, + /// with the spot colors it uses, as a solid color or in a gradient's stops, one in a group and a + /// type object whose characters use one included. A spot swatch no art uses adds no row; a + /// process color that looks the same, Registration and a tint of 0 are not spot colors; paint + /// that draws nothing is left out by the rules Pattern Objects follows; and Objects counts the + /// listed objects. With Selection Only it lists the objects in the selection, each once, and the + /// report writes the same rows. + #[test] + fn info_lists_the_spot_color_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // An empty document lists none. + let i = s.execute("document.info", &json!({"category": "spotColors"})).unwrap(); + assert_eq!((i["spotObjects"].clone(), i["spotColorNames"].clone(), i["spotColors"].clone()), (json!([]), json!([]), json!([]))); + assert!(i["objects"]["spots"].is_null() && i["sections"][0]["rows"] == json!([]), "{i}"); + let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nSPOT COLOR OBJECTS\nNone\n"), "{text}"); + let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0}); + s.execute("swatch.new", &json!({"name": "Ink", "color": ink(1.0), "spot": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Gold", "color": ink(0.2), "spot": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Spare", "color": ink(0.4), "spot": true})).unwrap(); + // A global process color of the same color as Ink. + s.execute("swatch.new", &json!({"name": "Look", "color": ink(1.0), "global": true})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let ramp = |a: &str, b: &str| json!({"kind": "linear", "stops": [{"offset": 0, "color": "#000", "swatch": a}, {"offset": 1, "color": "#fff", "swatch": b}]}); + let (a, b, c, f, g, h) = + (square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0), square(&mut s, 80.0), square(&mut s, 100.0)); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Gold", "tint": 40})).unwrap(); + s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [b], "swatch": "Look"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Ink", "tint": 50})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [c], "swatch": "Ink", "tint": 0})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [f], "gradient": ramp("Gold", "Look")})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [g], "gradient": ramp("Look", "Look")})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [h], "swatch": "[Registration]"})).unwrap(); + for id in [&c, &g, &h] { + s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap(); + } + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + // A type object whose characters are filled with a spot color. + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [t], "none": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + // A hidden spot fill and a spot stroke with no width paint nothing, so neither object is listed. + let (d, e) = (square(&mut s, 120.0), square(&mut s, 140.0)); + s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Ink"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Ink"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // Type with no characters, or of only spaces and a line break, paints nothing, and neither + // does a guide made from a path with a spot color. + let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Ink"})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap(); + let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Ink"})).unwrap(); + let guide = square(&mut s, 300.0); + s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Ink"})).unwrap(); + s.execute("select.set", &json!({"ids": [guide]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + // Nor does a compound path's member's own spot fill, or an image's spot fill without an + // effect that gives it a shape. + let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap(); + let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone(); + let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance); + let pic = square(&mut s, 360.0); + s.execute("select.set", &json!({"ids": [pic]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let image = s.doc().unwrap().selection.objects[0]; + s.edit("own paint", |d, _| { + d.node_mut(member).unwrap().appearance = paint.clone(); + d.node_mut(image).unwrap().appearance = paint; + Ok(()) + }) + .unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + i["spotObjects"], + json!([ + {"id": a, "name": "Both", "spotColors": ["Ink", "Gold"]}, + {"id": b, "name": "", "spotColors": ["Ink"]}, + {"id": f, "name": "", "spotColors": ["Gold"]}, + {"id": t, "name": "Type", "spotColors": ["Ink"]}, + ]) + ); + assert_eq!((i["spotColorNames"].clone(), i["spotColors"].clone()), (json!(["Gold", "Ink"]), json!(["Gold", "Ink"]))); + // Objects counts the objects that Spot Color Objects lists. + assert_eq!(i["objects"]["spots"].as_u64(), Some(4), "{}", i["objects"]); + let objects = s.execute("document.info", &json!({"category": "objects"})).unwrap()["sections"][0]["rows"].clone(); + assert!(objects.as_array().unwrap().contains(&json!(["Spot Color Objects", "4"])), "{objects}"); + s.execute("select.set", &json!({"ids": [c, g, h, d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["spots"].is_null() && i["spotObjects"] == json!([]) && i["spotColors"] == json!([]), "{i}"); + let i = s.execute("document.info", &json!({"category": "spotColors"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("spotColors"), Some("Spot Color Objects"))); + assert_eq!(i["sections"][0]["rows"], json!([["Both", "Ink, Gold"], ["", "Ink"], ["", "Gold"], ["Type", "Ink"]])); + // A selection that holds the group and the object inside it lists that object once. + for ids in [json!([group, b]), json!([b, group])] { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Ink"]]), "{ids}"); + assert_eq!((i["objects"]["spots"].as_u64(), i["spotColorNames"].clone()), (Some(1), json!(["Ink"])), "{ids}"); + } + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["spotColors"].clone()), (json!([["Type", "Ink"]]), json!(["Ink"]))); + let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nSPOT COLOR OBJECTS\nBoth: Ink, Gold\n: Ink\n: Gold\nType: Ink\n"), "{text}"); + assert!(!text.contains("Spare") && !text.contains("Look"), "{text}"); + } + + /// Spot Color Objects lists a grayscale image colorized with a spot color, and leaves out a + /// gradient whose stops are all at an opacity of 0, which draws nothing. + #[test] + fn spot_color_objects_take_colorized_images_and_skip_clear_gradients() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0, "m": 1, "y": 1, "k": 0}, "spot": true})).unwrap(); + let mut bytes = Vec::new(); + image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + let image = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(bytes)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + assert!(s.doc().unwrap().doc.node(image).unwrap().extras().colorize.is_some()); + let clear = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 10, "height": 10})).unwrap()["id"].clone(); + let stops = + json!([{"offset": 0, "color": "#000", "swatch": "Ink", "opacity": 0}, {"offset": 1, "color": "#fff", "swatch": "Ink", "opacity": 0}]); + s.execute("paint.setFill", &json!({"ids": [clear], "gradient": {"kind": "linear", "stops": stops}})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + let name = s.doc().unwrap().doc.node(image).unwrap().display_name(); + assert_eq!(i["spotObjects"], json!([{"id": image.0, "name": name, "spotColors": ["Ink"]}]), "{i}"); + assert_eq!(i["objects"]["spots"].as_u64(), Some(1), "{}", i["objects"]); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f3dc3403a..41949ddeb9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), @@ -38,7 +38,7 @@ pub fn specs() -> Vec { has_doc, doc_move ), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!( @@ -50,7 +50,11 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so are a data set's image value and a linked JavaScript file in svg_scripts outside them (a URL is kept); a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { + let st = s.doc()?; + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st)); + Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) + }), cmd!( "document.setUnits", "Units", diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1d39e2b4e4..6d4bcd54d9 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -194,10 +194,12 @@ impl Expandable { NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()), _ => shape && !c.appearance.stroke_paint().is_none(), }; - e.gradient |= match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), - _ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()), - }; + // 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(_))), + _ => shape && gradient_expands(d, c), + }; e.fill |= e.gradient; e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())), @@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> { } } +/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or +/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a +/// path that live-effect plug-ins paint, whether a piece of the art they paint has one +/// ([`expand_pieces`]). +fn gradient_expands(d: &Document, l: &Node) -> bool { + if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) { + return false; + } + if !vectorcraft_render::effects::has_painted_effects(l) { + return piece_gradient_expands(d, l); + } + let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false }; + let mut any = false; + art.walk(&mut |c| { + any = any + || (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && !c.is_ruler_guide() + && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()) + && piece_gradient_expands(d, c)); + }); + any +} + +/// [`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()) { + return l.appearance.items.iter().any(|i| gradient_fill(i).is_some()); + } + let reshaped = reshaped(d, l); + let l = reshaped.as_ref().unwrap_or(l); + let Some((path, _)) = node_path(l) else { return false }; + let Some(bounds) = path.bounds() else { return false }; + l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| { + fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some())) + }) +} + /// The definition and placement of a visible pattern fill whose pattern `d` defines. fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> { match i { @@ -476,11 +515,15 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?; vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))] } else { + // A gradient that repeats or reflects expands as its stops repeated over the box (and + // stays a gradient when the box needs more runs than are written). + let (g, runs) = padded_on(g, bounds)?; + let g = &*g; let frame = Frame::new(g, bounds); match to { GradientTo::Mesh => vec![Node::new(d.alloc_id(), NodeKind::Mesh(frame.mesh(&g.gradient)))], GradientTo::Objects(n) => frame - .pieces(&g.gradient, n as usize) + .pieces(&g.gradient, n as usize, runs) .into_iter() .map(|(shape, (color, opacity))| { let fill = FillLayer { opacity, overprint: f.overprint, ..FillLayer::new(Paint::solid(color)) }; @@ -543,12 +586,14 @@ fn expand_piece_leaves( } /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its -/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no +/// path (a plain path then), and every geometry effect of its own removed, with each raster effect +/// following the Transform effects after it +/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no /// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their /// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a /// path that one paints goes through [`expand_pieces`] instead. pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry}; if !has_geometry(&l.appearance.effects) { return None; } @@ -559,7 +604,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = without_geometry(&m.appearance.effects); Some(m) } @@ -601,9 +646,10 @@ fn with_path(l: &Node, path: PathData) -> Node { } /// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which -/// shape the outline it paints ([`fill_shape`]). +/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform +/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)). fn fill_effects(f: &FillLayer) -> Vec { - f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect() + vectorcraft_render::effects::without_geometry(&f.effects) } /// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]). @@ -1234,8 +1280,70 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model (super::paint::stored_color(store_in, c), a) }); } + let (geom, spread) = (g.resolve(bounds), g.gradient.spreader()); + Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32)) +} + +/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and +/// how many runs lie evenly from there to 1. +type Runs = Option<(f64, usize)>; + +/// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond +/// gradient that repeats or reflects with its stops repeated as many times as the box needs +/// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the +/// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated +/// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from +/// there to 1. `None` when the box needs more runs than are written +/// ([`vectorcraft_color::Padded::capped`]): the gradient stays. +fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> { + use std::borrow::Cow; + if !g.gradient.is_spread() { + return Some((Cow::Borrowed(g), None)); + } let geom = g.resolve(bounds); - Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32)) + match g.gradient.padded_over(&geom, bounds) { + Some(p) if p.capped => None, + Some(p) => { + let runs = Some((p.first, p.runs)); + Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)) + } + None => { + let runs = Some((0.0, g.gradient.repeat_count() as usize)); + Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)) + } + } +} + +/// The edges (gradient parameters, from `lo` to `hi`) of about `n` bands [`Frame::pieces`] cuts: +/// evenly spaced, or for stops repeated in `runs` ([`padded_on`]: where the first run starts and +/// how many runs lie evenly from there to 1) the same whole number of bands in each run between +/// `lo` and `hi` (at least one, about `n` in all), so every run starts on an edge and no band +/// holds two runs' colors. +fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec { + let n = n.max(1); + let even = || { + let step = (hi - lo) / n as f64; + (0..=n).map(|k| lo + k as f64 * step).collect() + }; + let Some((first, count)) = runs.filter(|&(first, count)| count > 0 && (0.0..1.0).contains(&first)) else { return even() }; + let run = (1.0 - first) / count as f64; + let (from, to) = (lo.max(first), hi.min(1.0)); + let shown = (to - from) / run; + if !(shown > 0.0 && shown.is_finite()) { + return even(); + } + let step = run / (n as f64 / shown).round().max(1.0); + // Both whole numbers of steps from `first`, at most `n` plus the runs shown, plus one. + let (k0, k1) = (((from - first) / step).ceil(), ((to - first) / step).floor()); + let mut edges = vec![lo]; + for k in (k0 as usize)..(k1 as usize).saturating_add(1) { + let t = first + k as f64 * step; + if t > edges.last().copied().unwrap_or(lo) + 1e-12 && t < hi - 1e-12 { + edges.push(t); + } + } + edges.push(hi); + edges } /// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every @@ -1404,10 +1512,17 @@ impl Frame { /// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the /// center from each band's start to the seam, for a diamond one, nested squares (largest /// first). Each paints over the ones below except its band, so no seam shows between bands. - /// With translucent stops each shape is just its band, so nothing doubles up. - fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { + /// With translucent stops each shape is just its band, so nothing doubles up. Stops repeated + /// in `runs` ([`padded_on`]) get the same whole number of bands in each run ([`band_edges`]). + fn pieces(&self, g: &Gradient, n: usize, runs: Option<(f64, usize)>) -> Vec<(PathData, (Color, f32))> { let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); + let color = |e: &[f64], k: usize| { + let (a, b) = (e.get(k).copied().unwrap_or(0.0), e.get(k + 1).copied().unwrap_or(1.0)); + g.sample_keep_model(((a + b) / 2.0) as f32) + }; if let Some((geom, b, _)) = &self.conic { + // Evenly spaced around the turn, as the wedges are. + let n = band_edges(0.0, 1.0, n, runs).len().saturating_sub(1); return geom .conic_wedges(*b, n, opaque) .into_iter() @@ -1419,51 +1534,52 @@ impl Frame { return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))]; }; // Nested squares about the center, largest first, as a radial gradient's ellipses. - let step = reach / n as f64; + let edges = band_edges(0.0, *reach, n, runs); let square = |t: f64| -> SubPath { let pts: Vec = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect(); SubPath::polyline(&pts, true) }; - return (0..n) + return (0..edges.len().saturating_sub(1)) .rev() .map(|k| { - let mut shape = PathData::single(square((k + 1) as f64 * step)); + let mut shape = PathData::single(square(edges.get(k + 1).copied().unwrap_or(*reach))); if !opaque && k > 0 { - let mut hole = square(k as f64 * step); + let mut hole = square(edges.get(k).copied().unwrap_or(0.0)); hole.reverse(); shape.subpaths.push(hole); } - (shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32)) + (shape, color(&edges, k)) }) .collect(); } - let step = (self.t1 - self.t0) / n as f64; - let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); + let edges = band_edges(self.t0, self.t1, n, runs); + let bands = edges.len().saturating_sub(1); + let edge = |k: usize| edges.get(k).copied().unwrap_or(self.t1); if self.radial { let ellipse = |t: f64| { let c = self.focal + (self.start - self.focal) * t; let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]); shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m) }; - return (0..n) + return (0..bands) .rev() .map(|k| { - let mut shape = ellipse((k + 1) as f64 * step); + let mut shape = ellipse(edge(k + 1)); if !opaque && k > 0 { - let mut hole = ellipse(k as f64 * step).subpaths.remove(0); + let mut hole = ellipse(edge(k)).subpaths.remove(0); hole.reverse(); shape.subpaths.push(hole); } - (shape, color(k)) + (shape, color(&edges, k)) }) .collect(); } - (0..n) + (0..bands) .map(|k| { - let a = self.t0 + k as f64 * step; - let b = if opaque { self.t1 } else { a + step }; + let a = edge(k); + let b = if opaque { self.t1 } else { edge(k + 1) }; let pts = [self.at(a, self.s0), self.at(b, self.s0), self.at(b, self.s1), self.at(a, self.s1)]; - (PathData::single(SubPath::polyline(&pts, true)), color(k)) + (PathData::single(SubPath::polyline(&pts, true)), color(&edges, k)) }) .collect() } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 61eac58f37..b7ff1c7734 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -66,12 +66,11 @@ use serde_json::{Value, json}; pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; -pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; +pub 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 load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; pub(crate) use load::is_placeholder; @@ -82,6 +81,7 @@ pub use load::{ Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image, read_for_open, texture_image, }; +pub(crate) use load::{check_files_not_lossy_overwrite, check_not_lossy_overwrite, lossy_source}; pub use load::{losses_summary, overwrite_losses}; pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label}; pub(crate) use mesh3d::{mesh_file_names, mesh_files}; @@ -130,7 +130,8 @@ mod tests_readonly; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{ - absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, + absolute_path, create_dir, document_path, file_created, file_stamp, naming_in_error, read_file, read_file_head, shown_link, shown_path, + write_file, write_new_file, }; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index c3668645e1..6488972d31 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -65,6 +65,62 @@ pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::bo std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) } +/// What an error names a file or folder outside the read roots by when [`shown_path`] gives no +/// name for it, or gives one that is not valid text. +pub(crate) const UNNAMED: &str = "a file or folder outside the read roots"; + +/// An error about `path`, a file or folder the client did not give, as the request being served +/// may see it: where the message names `path` ([`naming_in_message`]), it names it as +/// [`shown_path`] does, or as [`UNNAMED`] when that is empty or holds a replacement character. The +/// roots the message names and the reason stay whole. Unchanged when `path` is [`shown`]. +pub(crate) fn naming_in_error(e: EngineError, path: &str) -> EngineError { + if path.is_empty() || shown(path) { + return e; + } + naming_as(e, path, &error_name(path)) +} + +/// What an error names `path` by when it is outside the read roots: [`shown_path`], or +/// [`UNNAMED`] when that is empty or holds a replacement character. +pub(crate) fn error_name(path: &str) -> String { + let name = shown_path(path); + if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED.to_string() } else { name.into_owned() } +} + +/// [`naming_in_error`] with `path` named `name`, whether or not it is [`shown`] (a name worked out +/// earlier, for the request that prepared the work). +pub(crate) fn naming_as(e: EngineError, path: &str, name: &str) -> EngineError { + match e { + EngineError::Other(msg) => EngineError::Other(naming_in_message(&msg, path, name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: naming_in_message(&msg, path, name) }, + EngineError::ReadOnlyLocation(msg) => EngineError::ReadOnlyLocation(naming_in_message(&msg, path, name)), + e => e, + } +} + +/// `msg` with `path` given as `name` where it begins a path the message is about: at the start of +/// `msg` (`: `), after a `: ` (`... outside the write root : `) or after +/// `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message names +/// stays whole, even one inside `path`. +pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(path) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { @@ -466,3 +522,52 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> { Err(no_fs(path)) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use crate::file_access::{AutomationRoots, confine}; + + /// An error about a path the client did not give, under roots that don't let it be read: the + /// path is named by its file name (after a `/` or a `\`), or as [`UNNAMED`] when it ends in no + /// name or its name is not valid text, and every root and the reason stay whole. A path inside the + /// read roots, and any path without roots in force, stay whole. + #[test] + fn an_error_names_a_path_outside_the_read_roots_by_its_name() { + let base = std::env::temp_dir().join(format!("vc-access-naming-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (a, b, out) = (base.join("a"), base.join("b"), base.join("kept-out")); + for f in [&a, &b, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let text = |p: &std::path::Path| p.to_string_lossy().into_owned(); + let roots = AutomationRoots::new(&[a.as_path(), b.as_path()], &[a.as_path(), b.as_path()]).unwrap(); + let file = text(&out.join("doc.svg")); + let raw = confine(roots.as_ref(), || crate::file_access::check_write(&file)).unwrap_err(); + let shown_root = |p: &std::path::Path| crate::cmd::findfiles::plain(std::fs::canonicalize(p).unwrap()).display().to_string(); + let (ra, rb) = (shown_root(&a), shown_root(&b)); + let named = |e: EngineError, path: &str| confine(roots.as_ref(), || naming_in_error(e, path)).to_string(); + + assert_eq!(named(EngineError::Other(raw.clone()), &file), format!("automation path rejected: outside the write roots {ra}, {rb}: doc.svg")); + let io = named(EngineError::Other(format!("{file}: disk full")), &file); + assert_eq!(io, "doc.svg: disk full"); + let bad = named(EngineError::BadParams { cmd: "x".into(), msg: format!("{file}: not a file") }, &file); + assert_eq!(bad, "invalid parameters for `x`: doc.svg: not a file"); + // A save refused by the file system keeps its type and its code. + let read_only = confine(roots.as_ref(), || naming_in_error(EngineError::ReadOnlyLocation(format!("{file}: Permission denied")), &file)); + assert!(matches!(&read_only, EngineError::ReadOnlyLocation(_))); + assert_eq!(read_only.to_string(), format!("{}: doc.svg: Permission denied", crate::READ_ONLY_LOCATION)); + // A Windows path written with `\`, a path that ends in no name and a name that isn't text. + let win = r"C:\Users\me\secret\doc.svg"; + assert_eq!(named(EngineError::Other(format!("{win}: denied")), win), "doc.svg: denied"); + let folder = format!("{}/", text(&out)); + assert_eq!(named(EngineError::Other(format!("{folder}: denied")), &folder), format!("{UNNAMED}: denied")); + let lossy = text(&out.join("bad\u{FFFD}.svg")); + assert_eq!(named(EngineError::Other(format!("{lossy}: denied")), &lossy), format!("{UNNAMED}: denied")); + // Readable, or nothing confined: whole. + let inside = text(&a.join("x.svg")); + assert_eq!(named(EngineError::Other(format!("{inside}: denied")), &inside), format!("{inside}: denied")); + assert_eq!(naming_in_error(EngineError::Other(raw.clone()), &file).to_string(), raw); + let _ = std::fs::remove_dir_all(base); + } +} diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 4953785189..232d5ba1f4 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -1429,6 +1429,42 @@ mod tests { assert_eq!(written.len(), 2, "{written:?}"); } + /// A Data Sets batch whose Save step has no path saves the document's own file. When that file is + /// outside the client's read and write roots, each row's error keeps the write root and names the + /// file by its file name, with Stop on Error on and off. A source folder the client gives stays + /// whole in the error. + #[test] + fn dataset_batch_step_error_hides_document_path() { + use crate::file_access::{AutomationRoots, confine}; + + let base = tmp("roots-error"); + let (inside, outside) = (base.join("in"), base.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = with_data_sets(&["Alpha", "Beta"]); + let own = outside.join("doc.vectorcraft").to_string_lossy().into_owned(); + s.execute("document.save", &json!({"path": own})).unwrap(); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + for (stop, rows) in [(true, 1), (false, 2)] { + let p = json!({"source": "datasets", "stopOnError": stop, "commands": [{"command": "document.save", "params": {}}]}); + let r = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap(); + assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}"); + for row in r["processed"].as_array().unwrap() { + let e = row["error"].as_str().unwrap_or_default(); + let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft"); + assert_eq!(e, want, "{r}"); + } + assert!(!r.to_string().contains(&hidden), "{r}"); + } + let p = json!({"source": "folder", "folder": hidden, "commands": [{"command": "object.unlockAll", "params": {}}]}); + let e = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap_err().to_string(); + assert!(e.contains(&hidden), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + /// The names of the files in `dir`, sorted. fn file_names(dir: &std::path::Path) -> Vec { let mut names: Vec = diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 7f95b8e203..fe7c9d8f3e 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -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} (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; 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", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 16cf3b2631..0fcaa4bfc0 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -241,6 +241,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); for img in out.images { if !enc.linked.iter().any(|l| l.name == img.name) { enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) }); diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0f91007515..1be80f2c56 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -74,18 +74,104 @@ pub struct Encoded { pub linked: Vec, /// Image maps, each written beside its image under the image's name (raster `imageMap`). pub maps: Vec, - /// The encoder's warnings (features approximated or left out, options not applied yet). + /// The encoder's warnings: what the file leaves out of the document or writes differently + /// ([`NoteKind::Loss`]). pub warnings: Vec, + /// The encoder's notes on the options: options not applied yet or applied another way, and + /// what an option turns off ([`NoteKind::Setting`]). A reply gives them after the warnings + /// ([`Self::notes`]). + pub setting_notes: Vec, /// What joins the file's stem and the artboard name when there is a file per artboard /// (default `-`; EPS uses `_`, as the reference app names them). pub joiner: Option<&'static str>, } +/// What a note an export gives with its file is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoteKind { + /// Something of the document (its art, type, layers or artboards) that the file leaves out or + /// that reaches it changed: the format, its version or a limit can't hold it, or the export + /// couldn't write it. + Loss, + /// An option the export didn't apply or applied another way while the art reaches the file as + /// it looks, or something left out because an option turns it off. + Setting, +} + +/// A note an export gives with its file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Note { + /// What the note is about. + pub kind: NoteKind, + /// The note, in English. + pub text: String, +} + +impl Note { + /// A note about what the file loses ([`NoteKind::Loss`]). + pub fn loss(text: impl Into) -> Self { + Self { kind: NoteKind::Loss, text: text.into() } + } + + /// A note on the options ([`NoteKind::Setting`]). + pub fn setting(text: impl Into) -> Self { + Self { kind: NoteKind::Setting, text: text.into() } + } +} + +/// The texts of `notes` in the order a reply gives them: the losses first, then the notes on the +/// options, each kind in the order given (the status bar names the first). +pub fn note_texts(notes: &[Note]) -> Vec { + let of = |kind| notes.iter().filter(move |n| n.kind == kind).map(|n| n.text.clone()); + of(NoteKind::Loss).chain(of(NoteKind::Setting)).collect() +} + +/// Add `note` to `notes` unless a note with its text is there already. +pub(super) fn add_note(notes: &mut Vec, note: Note) { + if !notes.iter().any(|n| n.text == note.text) { + notes.push(note); + } +} + impl Encoded { pub(super) fn one(bytes: Vec) -> Self { Self { files: vec![(None, bytes)], ..Self::default() } } + /// The file `bytes` with `notes`, each in the list of its kind, in the order given. + pub(super) fn one_with_notes(bytes: Vec, notes: Vec) -> Self { + let mut enc = Self::one(bytes); + for n in notes { + match n.kind { + NoteKind::Loss => enc.warnings.push(n.text), + NoteKind::Setting => enc.setting_notes.push(n.text), + } + } + enc + } + + /// Add each of `texts` to [`Self::setting_notes`] unless a note with its text is there already. + pub(super) fn add_setting_notes(&mut self, texts: impl IntoIterator) { + for t in texts { + if !self.warnings.contains(&t) && !self.setting_notes.contains(&t) { + self.setting_notes.push(t); + } + } + } + + /// Every note with its kind: the warnings, then the notes on the options that aren't also + /// warnings. + pub fn kinded_notes(&self) -> Vec { + let settings = self.setting_notes.iter().filter(|t| !self.warnings.contains(t)).map(Note::setting); + self.warnings.iter().map(Note::loss).chain(settings).collect() + } + + /// The notes' texts in the order a reply gives them ([`note_texts`]): the warnings, then the + /// notes on the options, each once. + pub fn notes(&self) -> Vec { + note_texts(&self.kinded_notes()) + } + /// Every file to write for the destination `path` (a path or a file name): the file itself /// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard /// among several, as Save's master file, is the file itself), then the linked images beside it @@ -128,13 +214,14 @@ impl Encoded { out } - /// The one file and the warnings, when the export wrote nothing else. - fn single(self, f: &Format) -> Result<(Vec, Vec)> { + /// The one file and the notes, when the export wrote nothing else. + fn single(self, f: &Format) -> Result<(Vec, Vec)> { if !self.linked.is_empty() || !self.maps.is_empty() { return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label))); } + let notes = self.kinded_notes(); match <[_; 1]>::try_from(self.files) { - Ok([(_, bytes)]) => Ok((bytes, self.warnings)), + Ok([(_, bytes)]) => Ok((bytes, notes)), Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))), } } @@ -419,8 +506,14 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { /// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet, /// features approximated or left out; every format that draws type: fonts written in the fallback -/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame). +/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame), what +/// the file loses first ([`note_texts`]). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { + encode_with_notes(doc, format, p).map(|(bytes, notes)| (bytes, note_texts(¬es))) +} + +/// Like [`encode_with_warnings`], with each note's kind ([`Encoded::kinded_notes`]). +pub fn encode_with_notes(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { let f = super::writable(C, Some(format), None)?; let enc = encode_all(doc, f.id, p)?; // The error for a version 7 .ai of several artboards names the legacy .ai. @@ -476,7 +569,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { /// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON /// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else /// one named after the document) with that path's extension when the plug-in lists it, else its -/// first. Its warnings come back with the file. +/// first. Its warnings come back with the file, as losses (the plug-in gives them no kind). fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result { use vectorcraft_plugins::{Error, export}; let doc = &*doc.without_edit_modes(); @@ -539,8 +632,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { "hpgl" => return super::hpgl::encode(doc, p, use_artboards), "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { - let (bytes, warnings) = super::pdf::encode(C, doc, p)?; - return Ok(Encoded { warnings, ..Encoded::one(bytes) }); + let (bytes, notes) = super::pdf::encode(C, doc, p)?; + return Ok(Encoded::one_with_notes(bytes, notes)); } "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => { let o: RasterOptions = options(f, p)?; @@ -607,7 +700,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let regions: Vec = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect(); enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); if format == RasterFormat::WebP && o.lossless == Some(false) { - enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); + enc.setting_notes.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); } return Ok(enc); } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index fe962a36ad..3acd23e854 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -382,6 +382,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); Ok(enc) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 62e5b78e6b..19e496306f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -42,7 +42,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { } _ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; - let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings })); + let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.notes() })); if main.len() > 1 { out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect(); } @@ -69,7 +69,7 @@ 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)?; - write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) + write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() })) } /// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait` diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index ff6e63fbd7..1baac844e6 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -116,6 +116,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 1fd3e10a9f..6ee826d5ea 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -136,10 +136,12 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } let images = see_through_images(&d, mode); if !images.is_empty() { - note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); if mode == SeeThrough::Flatten { + note(&mut enc.warnings, FLATTENED); flatten_images(&mut d, &images)?; } else { + // A note on the options: `transparentImages: omit` leaves them out. + enc.add_setting_notes([OMITTED.to_string()]); for (_, id) in images { let _ = d.remove(id); // Inside an image left out before: gone already. } @@ -150,6 +152,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); } + enc.add_setting_notes(out.setting_notes); enc.files.push((Some(b), out.bytes)); } // Expanding raster effects and flattening images draw the art, and an image the renderer diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ec1d7e36ba..6f0f8fd533 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); // Relative links are found in the SVG's folder (when `name` is a path). let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); - restore_or_import(editing, || import_svg(&text, folder.as_deref()))? + restore_or_import(editing, || open_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // The file is read and checked once, for the document it carries, its pages, the @@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result /// own): rasters stay linked, SVG files become art, missing ones placeholders (see /// [`vectorcraft_svg::ImportOptions`]). pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings)) +} + +/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG +/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable +/// data that can't be read is left out with a warning; the art opens either way. +fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + let lib = match crate::cmd::variablecmds::svg_library(text) { + None => return import_svg(text, folder), + Some(Err(e)) => { + let (doc, mut warnings) = import_svg(text, folder)?; + warnings.push(format!("the file's variable data wasn't read: {e}")); + return Ok((doc, warnings)); + } + Some(Ok(lib)) => lib, + }; + let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?; + warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found)); + Ok((doc, warnings)) +} + +/// [`import_svg`], also returning the object made of each element whose id `ids` lists. +fn import_svg_finding( + text: &str, + folder: Option<&str>, + ids: &std::collections::HashSet, +) -> Result<(Document, Vec, vectorcraft_svg::Found)> { // The importer reads on a thread of its own (for its stack): what may be read is what may be // read here. let scope = crate::file_access::ScopeHandle::capture(); @@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Some((bytes, link)) }) }; - vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) + vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err) } /// Open a file's bytes as the new active document (what `document.open` does) → @@ -1019,13 +1046,38 @@ fn is_loss(note: &str) -> bool { /// Another name keeps the original file, with all it has. pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> { let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) }; + Err(lossy_overwrite(target, losses, cmd)) +} + +/// The refusal to write `target`, the file the document was read from, when reading it left out `losses`. +fn lossy_overwrite(target: &str, losses: &[String], cmd: &str) -> EngineError { let what = losses_summary(losses); - Err(bad( + bad( cmd, format!( "{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway" ), - )) + ) +} + +/// The file the document was read from and what reading it left out, when a write to that file +/// needs `acknowledgeLoss` and `p` does not have it ([`overwrite_losses`]). +pub(crate) fn lossy_source(st: &crate::DocState, p: &Value) -> Option<(String, Vec)> { + let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?; + (!bool_or(p, "acknowledgeLoss", false)).then(|| (source.to_string(), st.import_losses.clone())) +} + +/// None of the files `targets` (every file a save writes, before any is written) is the +/// `source` file that reading left `losses` out of ([`lossy_source`]). +pub(crate) fn check_files_not_lossy_overwrite<'a>( + source: &(String, Vec), + targets: impl IntoIterator, + cmd: &str, +) -> Result<()> { + match targets.into_iter().find(|t| same_file(&source.0, t)) { + Some(target) => Err(lossy_overwrite(target, &source.1, cmd)), + None => Ok(()), + } } /// What writing the file `target` for document `st` would lose for good: the notes of what reading diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1f166797f8..b11251ba34 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -12,7 +12,7 @@ use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, S use super::super::flatten::{FlattenOptions, flatten_document, see_through_images}; use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; -use super::{ArtboardPick, FormatOption, write_or_return}; +use super::{ArtboardPick, FormatOption, Note, note_texts, write_or_return}; use crate::EngineError; const C: &str = "document.exportPdf"; @@ -284,21 +284,22 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; -/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered +/// Encode `doc` as PDF with the options in `p` → (bytes, notes). Raster effects are rendered /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). -pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { +pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { encode_at(cmd, doc, p, None) } /// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. -pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. - let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; - warnings.extend(drawn_warnings); - warnings.extend(overflow_warning(cmd, &full, p)?); - Ok((bytes, warnings)) + let (full, placed) = crate::cmd::place::document::full_documents(doc); + let mut notes: Vec = placed.into_iter().map(Note::loss).collect(); + let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + notes.extend(drawn); + notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss)); + Ok((bytes, notes)) } /// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and @@ -321,7 +322,7 @@ pub(super) fn encode_carrying( p: &Value, native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec)> { encode_carrying_at(cmd, doc, p, None, native, private) } @@ -334,7 +335,7 @@ fn encode_carrying_at( modified: Option, native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec)> { let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); @@ -345,21 +346,22 @@ fn encode_carrying_at( if opts.settings.thumbnails { opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; } - let mut warnings = vec![]; - // The status bar shows the first warning, so this one goes ahead of the flattening note. + let mut notes = vec![]; + // A loss, ahead of the flattening note: the PDF/X pages leave out the bleed that Document + // Setup gives. if opts.settings.leaves_out_bleed(doc) { - warnings.push(BLEED_LEFT_OUT.to_string()); + notes.push(Note::loss(BLEED_LEFT_OUT)); } if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); if let Some((data, lost)) = private()? { opts.private_data = Some(data); - warnings.extend(lost); + notes.extend(lost.into_iter().map(Note::loss)); } } else { opts.settings.preserve_editing = false; - warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); + notes.push(Note::setting(EDITING_NEEDS_EVERY_ARTBOARD)); } } let set = &opts.settings; @@ -370,12 +372,13 @@ fn encode_carrying_at( vectorcraft_doc::cancel::check()?; if flattened { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; - warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); + notes.push(Note::loss(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"))); } let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?; - warnings.extend(r.warnings); - warnings.extend(crate::cmd::fonts::substitution_warning(doc)); - Ok((r.bytes, warnings)) + notes.extend(r.warnings.into_iter().map(Note::loss)); + notes.extend(r.setting_notes.into_iter().map(Note::setting)); + notes.extend(crate::cmd::fonts::substitution_warning(doc).map(Note::loss)); + Ok((r.bytes, notes)) } /// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty. @@ -479,8 +482,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } else { q = json!({ "_displayDocTitle": display_doc_title }); } - let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; - write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) + let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; + write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) })) } /// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`). @@ -512,7 +515,7 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { let set = settings_with(Q, p, saved)?; let diff = changes(&set, &preset_settings(Q, p, saved)?); let warnings = match s.active() { - Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1, + Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1), _ => set.warnings(), }; let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 58677cd5d4..e1cbf68bf2 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -99,6 +99,10 @@ pub struct SavePlan { /// `preview`, `precision`) given beside the path, and each key of `options`, that the format /// doesn't read, naming the file the save writes. pub notes: Vec, + /// The file the document was read from and what reading it left out, when the save needs + /// `acknowledgeLoss` to write over it and was not given it: every file the save writes is + /// checked against it before the first is written. + pub lossy_source: Option<(String, Vec)>, } impl SavePlan { @@ -303,7 +307,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { }; let modified = date_param(p, "modified", cmd)?; if let Some(path) = &path { - super::check_not_lossy_overwrite(st, path, p, cmd)?; + // Without a path, it is the document's own file: the error names it as the request may see it. + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?; } let written = written_as(cmd, format, &Value::Object(options.clone())); let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string()); @@ -313,7 +319,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { .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)) .collect(); - Ok(SavePlan { mode, path, format, options, name, folder, modified, notes }) + let lossy_source = super::lossy_source(st, p); + Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source }) } /// The native save options, which a save also takes beside the path. @@ -745,12 +752,14 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res carries_editing.set(true); Ok(Some((data, resolved_warnings))) }; - let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; - warnings.extend(placed_warnings); + let (bytes, notes) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; + let mut enc = Encoded::one_with_notes(bytes, notes); + enc.warnings.extend(placed_warnings); + // A note on the options: `pdfCompatible: false` writes blank pages. if !pdf_compatible { - warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); + enc.setting_notes.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); } - Ok(Encoded { warnings, ..Encoded::one(bytes) }) + Ok(enc) } /// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the @@ -838,8 +847,8 @@ impl SaveJob { } /// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with - /// the warnings (what the format loses first, then the encoder's own notes: PDF options not - /// applied yet…, then the plan's notes on its params). + /// the notes (what the format loses first, then the encoder's own notes: PDF options not + /// applied yet…, then the plan's notes on its params, which are notes on the options). pub fn encode(&self) -> Result { let mut enc = self.encode_one(&self.doc)?; for &b in &self.boards { @@ -847,7 +856,7 @@ impl SaveJob { enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); } enc.warnings.splice(0..0, self.format_losses()); - enc.warnings.extend(self.plan.notes.iter().cloned()); + enc.setting_notes.extend(self.plan.notes.iter().cloned()); Ok(enc) } @@ -898,9 +907,8 @@ impl SaveJob { /// order: when `stop` returns a value, nothing is written and that value is returned. pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let (plan, doc) = (&self.plan, &*self.doc); - let mut enc = self.encode()?; - let warnings = std::mem::take(&mut enc.warnings); - let extra = json!({ "format": plan.format.id, "warnings": warnings }); + let enc = self.encode()?; + let extra = json!({ "format": plan.format.id, "warnings": enc.notes() }); let Some(path) = &plan.path else { let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; out["name"] = json!(plan.name); @@ -910,6 +918,9 @@ impl SaveJob { return Ok(out); }; let files = enc.named(doc, path); + if let Some(source) = &plan.lossy_source { + super::check_files_not_lossy_overwrite(source, files.iter().map(|(p, _)| p.as_str()), plan.mode.command())?; + } if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::>()) { return Ok(stopped); } @@ -1021,6 +1032,8 @@ impl SaveJob { } // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); + // A save of the arrowheads file lists its heads in the arrowhead menus. + let native = self.plan.format.id == "vectorcraft"; if let Some(st) = s.document_mut(uid) { self.finish(st); } @@ -1028,6 +1041,9 @@ impl SaveJob { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { + if native { + s.arrowheads_saved(&path); + } crate::cmd::links::refresh_placed(s, uid, &path); } } @@ -1048,7 +1064,12 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let mut out = job.write_streamed(&mut super::write_save_file_with)?; + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + // Without a path, the files written are the document's own file and those beside it. + let mut write = |path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + super::write_save_file_with(path, fill).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) }) + }; + let mut out = job.write_streamed(&mut write)?; job.complete(s, uid); leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) @@ -1103,7 +1124,8 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); + let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?; + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..91f43cfd36 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -17,12 +17,12 @@ use vectorcraft_doc::Document; use vectorcraft_geom::Rect; use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param}; -use super::encode::single_artboard; +use super::encode::{add_note, single_artboard}; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, - unique_file_names_keeping_dots, with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge, + note_texts, unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -649,59 +649,34 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { let p = with_preset(s, p)?; let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; let mut r = export(s, &p).map_err(|e| match hidden { - Some(folder) => leave_out_folder_from_error(e, folder), + Some(folder) => super::access::naming_in_error(e, folder), None => e, })?; remember(s, &p); + // A prepared export is written later, maybe on another thread: it names the folder as this + // request may see it. + if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) { + export.hidden = Some((folder.to_string(), super::access::error_name(folder))); + } if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); } Ok(r) } -/// An error of an export into `folder`, a folder outside the read roots that the client did not -/// give: `folder` as the path the error is about names it by its own name -/// ([`super::access::shown_path`]), and the rest of the message stays. -fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { - let name = super::access::shown_path(folder); - match e { - EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), - EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, - e => e, - } -} - -/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start -/// of `msg` (`: `), after a `: ` (`... outside the write root : `) or -/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message -/// names stays whole, even one inside `folder`. -fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { - const BEFORE: [&str; 2] = [": ", "cannot resolve "]; - let mut out = String::with_capacity(msg.len()); - let mut rest = msg; - loop { - if let Some(after) = rest.strip_prefix(folder) - && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) - { - out.push_str(name); - rest = after; - } - let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; - out.push_str(&rest[..i + len]); - rest = &rest[i + len..]; - } - out.push_str(rest); - out -} - /// The reply `r` of an export into `folder`, a folder the client did not give, as the request being /// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), /// as the reply of an export to a ZIP names them. fn leave_out_unreadable_folder(r: &mut Value, folder: &str) { - if super::access::shown(folder) { - return; + if !super::access::shown(folder) { + leave_out_folder(r, folder); } +} + +/// The reply `r` of an export into `folder` with the ZIP's `path` left out and `files` named +/// relative to `folder` ([`leave_out_unreadable_folder`]). +fn leave_out_folder(r: &mut Value, folder: &str) { let Some(o) = r.as_object_mut() else { return }; o.remove("path"); let inside = format!("{folder}/"); @@ -1046,7 +1021,8 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map Result { check_passwords(p)?; let o = ScreenParams::of(p)?; @@ -1074,7 +1050,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { // Include Artboard Fill off: the encoders leave the background colors out. let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) }; let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect(); - let export = ScreenExport { cmd: C, o, rows, naming, items }; + let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None }; match &mut s.screens_hand_off { Some(slot) => { *slot = Some(export); @@ -1095,9 +1071,27 @@ pub struct ScreenExport { rows: Vec, naming: Naming, items: Vec, + /// The export folder and the name the request that prepared the export may see it by, when the + /// folder came from the `exportPreset` and is outside that request's read roots: the reply and + /// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does. + hidden: Option<(String, String)>, } impl Session { + /// The folder `document.exportForScreens` (`id`) with `params` writes into, the client's + /// `folder` or else the one its `exportPreset` keeps, with the name the request being served may + /// see it by when the reply leaves it out: a preset's folder outside the read roots, named by + /// its own name ([`super::access::shown_path`]). `assets.export` takes no preset, so for it the + /// folder is the client's. → `None` without a folder (the files come back in the reply) or for + /// an unknown preset, which the export itself refuses. + pub fn screen_export_folder(&self, id: &str, params: &Value) -> Option<(String, Option)> { + let given = params.get("folder").is_some(); + let p = if given || id == ASSETS { params.clone() } else { with_preset(self, params).ok()? }; + let folder = str_param(&p, "folder").filter(|f| !f.is_empty())?.to_string(); + let hidden = (!given && !super::access::shown(&folder)).then(|| super::access::shown_path(&folder).into_owned()); + Some((folder, hidden)) + } + /// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as /// a command (checked and journaled as any other; Export for Screens remembers its settings) /// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`] @@ -1123,10 +1117,19 @@ impl ScreenExport { /// return → the reply of `document.exportForScreens`. It reports one progress step per file /// ([`vectorcraft_doc::cancel::progress`]), and a cancel stops it before it encodes or writes /// another file. - pub fn write(self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + pub fn write(mut self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = self.cmd; + let hidden = self.hidden.take(); // A refusal names `assets.export` when that command prepared the export, as its reply does. - encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }) + let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }); + match hidden { + Some((folder, name)) => { + let mut r = r.map_err(|e| super::access::naming_as(e, &folder, &name))?; + leave_out_folder(&mut r, &folder); + Ok(r) + } + None => r, + } } } @@ -1138,18 +1141,16 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> create_dir(dir)?; } let mut out = Output::default(); - // The encoders' warnings for every file, each once. - let mut warnings: Vec = Vec::new(); + // The encoders' notes for every file, each once. + let mut notes: Vec = Vec::new(); // Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are // written on a page of their own bounds, which it doesn't grow. let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none(); let mut write = |doc: &Document, format: &str, options: &Value| -> Result> { // The steps are the files: an encoder's own steps (artboards, pages) are not reported. - let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; - for w in notes { - if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) { - warnings.push(w); - } + let (bytes, given) = vectorcraft_doc::cancel::quiet(|| encode_with_notes(doc, format, options))?; + for n in given.into_iter().filter(|n| !(bleed_on_page && n.text == super::pdf::BLEED_LEFT_OUT)) { + add_note(&mut notes, n); } Ok(bytes) }; @@ -1224,6 +1225,8 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> if o.zip || folder.is_none() { vectorcraft_doc::cancel::check()?; } + // What the files lose first, across every file. + let warnings = note_texts(¬es); let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); if o.zip { let zip = super::zip::deflate(&out.kept, super::zip::FIXED_TIME, vectorcraft_doc::cancel::is_cancelled); @@ -1475,7 +1478,8 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { #[cfg(test)] mod tests { - use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + use super::super::access::naming_in_message; + use super::{Output, ScreenSize, safe_file_name}; /// A hidden export folder is named by its name where a message is about it, and a root that /// starts with the same text, or a longer path, stays whole. @@ -1492,7 +1496,7 @@ mod tests { ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), ]; for (msg, want) in cases { - assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + assert_eq!(naming_in_message(msg, f, "out"), want, "{msg}"); } } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..969b12467e 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)", }, + FormatOption { + name: "variableData", + ty: "boolean", + default: "false", + description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning", + }, FormatOption { name: "svg", ty: "object", @@ -198,6 +204,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let Some((option, losses)) = warnings.split_last() else { panic!("no notes") }; + assert!(option.starts_with("fill hatches are left out"), "{warnings:?}"); + assert!(losses.iter().any(|w| w.starts_with("transparency needs DXF 2004")), "{warnings:?}"); + assert!(!losses.iter().any(|w| w.starts_with("fill hatches")), "each note once: {warnings:?}"); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs index 09bbcf28f3..f3566e82ba 100644 --- a/crates/engine/src/cmd/fileio/tests_exportas.rs +++ b/crates/engine/src/cmd/fileio/tests_exportas.rs @@ -117,3 +117,35 @@ fn formats_list_use_artboards() { } assert!(opts("vectorcraft").get("useArtboards").is_none()); } + +/// An export's reply gives what the file loses before its notes on the options, whatever order +/// the encoder gave them: the WebP encoder notes that lossy WebP isn't applied before the export +/// notes the type it drew in the fallback font. With only notes on the options, they keep their +/// order. +#[test] +fn what_the_file_loses_comes_before_a_note_on_the_options() { + let lossy = "lossy WebP isn't available yet: the file is lossless (exact, but larger)"; + let warnings = |s: &mut Session, p: Value| -> Vec { + let r = s.execute("document.export", &p).unwrap(); + r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() + }; + let mut s = session(60.0, 40.0, 1); + assert_eq!(warnings(&mut s, json!({"format": "webp", "lossless": false})), [lossy]); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let w = warnings(&mut s, json!({"format": "webp", "lossless": false})); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].contains("fallback font") && w[0].contains("Nonexistent Sans Probe"), "{w:?}"); + assert_eq!(w[1], lossy); + // document.serialize gives them in the same order. + let r = s.execute("document.serialize", &json!({"format": "webp", "lossless": false})).unwrap(); + assert_eq!(r["warnings"], json!(w)); +} + +#[test] +fn note_texts_give_the_losses_first_each_kind_in_order() { + use super::encode::{Note, note_texts}; + let notes = [Note::setting("s1"), Note::loss("l1"), Note::setting("s2"), Note::loss("l2")]; + assert_eq!(note_texts(¬es), ["l1", "l2", "s1", "s2"]); + assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]); + assert!(note_texts(&[]).is_empty()); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs index d8c36a9196..09505391af 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs @@ -56,3 +56,21 @@ fn permissions_without_a_password_warn_and_a_password_applies_them() { let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap(); assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt")); } + +/// The writer notes the permissions it didn't apply before the export notes the type it drew in +/// the fallback font: the reply gives what the file loses first, through both commands. +#[test] +fn the_fallback_font_note_comes_before_the_permissions_note() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + for r in [ + s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap(), + s.execute("document.export", &json!({"format": "pdf", "security": {"printing": "none"}})).unwrap(), + ] { + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let at = |s: &str| warnings.iter().position(|w| w.contains(s)); + let (font, permissions) = (at("Nonexistent Sans Probe"), at("permissions need a password")); + assert!(font.is_some() && permissions.is_some() && font < permissions, "{warnings:?}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 0b529c87c6..51954d8da3 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -507,6 +507,22 @@ fn type_in_a_missing_family_is_named_once_for_every_format() { assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}"); } +/// What a later file loses comes before a note on the options that an earlier file gave: the +/// first artboard's WebP gives only the lossy WebP note, the second's also says its type doesn't +/// fit. +#[test] +fn what_any_file_loses_comes_before_the_notes_on_the_options() { + let mut s = session(2); + let second = s.doc().unwrap().doc.artboards[1].rect; + let area = json!({"width": 30, "height": 8}); + s.execute("text.create", &json!({"x": second.x0 + 2.0, "y": second.y0 + 2.0, "size": 12, "area": area, "text": "Ab\nCd\nEf\nGh"})).unwrap(); + let r = export(&mut s, json!({"formats": [{"format": "webp", "lossless": false}]})); + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(warnings.len(), 2, "{warnings:?}"); + assert!(warnings[0].contains("doesn't fit its frame"), "{warnings:?}"); + assert!(warnings[1].starts_with("lossy WebP"), "{warnings:?}"); +} + #[test] fn full_document_gives_one_file_per_format() { let mut s = session(3); @@ -735,6 +751,81 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ assert!(e.contains(&format!("root {root}: {name}")), "{e}"); } +/// A prepared export into the folder an export preset keeps, prepared for a client whose read roots +/// don't hold that folder and written later outside that client's scope (as on the background +/// thread): its reply leaves out the ZIP's path and names the files relative to the folder, and a +/// refused write names the folder by its name. A folder inside the read roots, and one the client +/// gives, stay whole. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("prepared-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap(); + let prepare = |s: &mut Session, roots: &Option>, p: Value| { + confine(roots.as_ref(), || s.prepare_screen_export("document.exportForScreens", &p)).unwrap() + }; + + let (r, files) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}"); + assert!(files.contains_key(&format!("{folder}/1x/Artboard-1.png")), "the files are written into the folder"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}))); + assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}"); + assert!(!r.to_string().contains(&folder), "{r}"); + // A refused write names the folder by its name. + let refused = prepare(&mut s, &write_only, json!({"exportPreset": "Kept"})); + let e = refused.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains("kept-out/1x/Artboard-1.png: access denied") && !e.contains(&folder), "{e}"); + // Inside the read roots, or given by the client: whole. + let (r, _) = write_prepared(prepare(&mut s, &both, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!([format!("{folder}/1x/Artboard-1.png")]), "{r}"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}))); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); + let given = prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder})); + let e = given.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains(&folder), "{e}"); +} + +/// [`Session::screen_export_folder`]: the client's folder, else the one the export preset keeps, +/// with its own name when the request's read roots don't hold it; none without a folder, for an +/// unknown preset or for `assets.export`, which takes no preset. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_export_folder_is_the_clients_else_the_presets() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("export-folder"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let ask = |id: &str, p: Value| confine(confined.as_ref(), || s.screen_export_folder(id, &p)); + + let screens = "document.exportForScreens"; + assert_eq!(ask(screens, json!({"exportPreset": "Kept"})), Some((folder.clone(), Some("kept-out".to_string())))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": "/x/y"})), Some(("/x/y".to_string(), None))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": ""})), None); + assert_eq!(ask(screens, json!({"exportPreset": "Nope"})), None); + assert_eq!(ask(screens, json!({})), None); + assert_eq!(ask("assets.export", json!({"exportPreset": "Kept"})), None); + assert_eq!(s.screen_export_folder(screens, &json!({"exportPreset": "Kept"})), Some((folder, None)), "unconfined: whole"); +} + /// The export settings the document keeps and the saved presets, read by a client whose read roots /// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and /// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index bf51ebdac7..9340e5a8c6 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -43,12 +43,62 @@ impl Visitor for Blocked { } } +/// A test's folder tree, removed with everything in it when dropped, also when the test panics, +/// and also when the test locked a folder in it. A removal that fails (a walker still holding a +/// file open) is ignored. +struct Tree(PathBuf); + +impl std::ops::Deref for Tree { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for Tree { + fn drop(&mut self) { + if std::fs::remove_dir_all(&self.0).is_err() { + unlock(&self.0); + let _ = std::fs::remove_dir_all(&self.0); + } + } +} + +/// Gives the owner read, write and search permission on `folder` and on every folder in it, so +/// that a tree holding a folder a test locked can be removed. Links are not followed. +#[cfg(unix)] +fn unlock(folder: &Path) { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::symlink_metadata(folder) else { return }; + if !meta.is_dir() { + return; + } + let mode = meta.permissions().mode() & 0o7777; + if mode & 0o700 != 0o700 { + let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode | 0o700)); + } + let Ok(entries) = std::fs::read_dir(folder) else { return }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_dir()) { + unlock(&entry.path()); + } + } +} + +/// Folders on other systems are removed as they are. +#[cfg(not(unix))] +fn unlock(_: &Path) {} + /// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where -/// they end in `/`. -fn tree(tag: &str, paths: &[&str]) -> PathBuf { - let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); +/// they end in `/`. The folder is removed when the returned [`Tree`] is dropped. +fn tree(tag: &str, paths: &[&str]) -> Tree { + let mut root = Tree(vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"))); + // Empties what a tree whose removal failed left, keeping the folder as `temp_dir` made it + // (owner-only on Unix). + for entry in std::fs::read_dir(&root.0).unwrap().flatten() { + let path = entry.path(); + let _ = if entry.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) }; + } for p in paths { let path = root.join(p); if p.ends_with('/') { @@ -58,7 +108,54 @@ fn tree(tag: &str, paths: &[&str]) -> PathBuf { std::fs::write(&path, b"font").unwrap(); } } - plain(std::fs::canonicalize(&root).unwrap()) + root.0 = plain(std::fs::canonicalize(&root.0).unwrap()); + root +} + +#[test] +fn a_tree_is_removed_when_its_test_ends() { + let path = { + let root = tree("removed", &["a/b/one.ttf", "c/"]); + assert!(root.join("a/b/one.ttf").is_file() && root.join("c").is_dir()); + root.to_path_buf() + }; + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn a_tree_is_removed_when_its_test_panics() { + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("unwound", &["a/b/one.ttf"]); + path = root.to_path_buf(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_is_private_to_this_user() { + use std::os::unix::fs::PermissionsExt; + let root = tree("private", &["a/one.ttf"]); + let mode = std::fs::metadata(&*root).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "{}: mode {mode:o}", root.display()); +} + +#[cfg(unix)] +#[test] +fn a_tree_with_a_locked_folder_is_removed_when_its_test_panics() { + use std::os::unix::fs::PermissionsExt; + let mut path = PathBuf::new(); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let root = tree("locked", &["a/locked/b/one.ttf"]); + path = root.to_path_buf(); + std::fs::set_permissions(root.join("a/locked"), std::fs::Permissions::from_mode(0o000)).unwrap(); + panic!("a failed assertion"); + }); + assert_eq!(r.err().as_deref(), Some("a failed assertion")); + assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display()); } /// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder. diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 4a42f03d4f..98f53b102e 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -20,6 +20,9 @@ //! and they lie the same way relative to each other: the group as a whole is moved or scaled. //! With Appearance on, each of those objects must have the same fill and stroke too; with Size, //! the groups the same width and height. +//! - Selecting one of the repeated objects with the Selection tool highlights the similar objects +//! at once, before Global Edit starts. Here they are the objects Start Global Edit would take +//! with the current Global Edit Options ([`similar`]). //! - While Global Edit is on, an edit made to one of the found objects is made to all of them, //! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt //! match to 10 pt. Stop Global Edit (or Escape) ends it. @@ -780,18 +783,55 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas -/// highlights them while Global Edit is on. +/// highlights them while Global Edit is on. While it is off, with one supported object selected, +/// they are the outlines of the objects Start Global Edit would take with the Global Edit Options +/// ([`similar`]). pub fn highlights(s: &Session) -> Outlines { let Some(st) = s.active() else { return vec![] }; - let Some(ge) = &st.global_edit else { return vec![] }; + let Some(ge) = &st.global_edit else { return similar(s) }; if let Some((rev, out)) = &*ge.outlines.borrow() && *rev == st.revision { return out.clone(); } + let out = outlines(st, &ge.members); + *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); + out +} + +/// [`similar`]'s outlines as of a document revision and the options they were found with. +pub type SimilarCache = std::cell::RefCell>; + +/// Before Global Edit starts: with one supported object selected, the outlines of the other +/// objects Start Global Edit would take now with the session's Global Edit Options, so the canvas +/// shows them as soon as the object is selected. None while an interaction (a drag) is in +/// progress, or when fewer than two objects would be taken. Kept per document revision and +/// options, as finding them walks the whole document. +pub fn similar(s: &Session) -> Outlines { + let Some(st) = s.active() else { return vec![] }; + if st.global_edit.is_some() || st.interaction.is_some() || st.selection.objects.len() != 1 { + return vec![]; + } + let o = &s.global_edit_options; + if let Some((rev, opts, out)) = &*st.similar_outlines.borrow() + && *rev == st.revision + && opts == o + { + return out.clone(); + } + let out = match matches(s, o) { + Ok(m) if m.len() >= 2 => outlines(st, &m), + _ => vec![], + }; + *st.similar_outlines.borrow_mut() = Some((st.revision, o.clone(), out.clone())); + out +} + +/// The outlines of `members` that are not selected, hold no selected object and sit inside no +/// selected object, in their layer's color. +fn outlines(st: &crate::DocState, members: &[(NodeId, Affine)]) -> Outlines { let symbols = st.doc.symbol_boxes(); - let out: Vec<_> = ge - .members + members .iter() .filter_map(|(id, _)| { let n = st.doc.node(*id).filter(|n| !holds(n, &st.selection.objects))?; @@ -804,9 +844,7 @@ pub fn highlights(s: &Session) -> Outlines { }; Some((path, st.doc.layer_color(*id))) }) - .collect(); - *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); - out + .collect() } #[cfg(test)] diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9003e325a9..090a7cb46c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -1,7 +1,8 @@ //! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector. use serde_json::{Value, json}; -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::gradient::MAX_REPEATS; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode}; use vectorcraft_geom::{Affine, Point, Rect}; @@ -23,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), 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 (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", has_doc, edit_gradient ), @@ -129,6 +130,36 @@ fn method_param(p: &Value) -> Parsed> { } } +/// The `spread` param of `p`, if given: what the gradient paints past the ends of its stops. +fn spread_param(p: &Value) -> Parsed> { + match p.get("spread") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(GradientSpread::parse).map(Some).ok_or_else(|| "`spread` must be pad, repeat or reflect".into()), + } +} + +/// The `repeats` param of `p`, if given: how many times the stops run along the vector with +/// Repeat and Reflect, a whole number from 1 to [`MAX_REPEATS`]. +fn repeats_param(p: &Value) -> Parsed> { + match p.get("repeats") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|n| n.fract() == 0.0 && (1.0..=f64::from(MAX_REPEATS)).contains(n)) + .map(|n| Some(n as u32)) + .ok_or_else(|| format!("`repeats` must be a whole number from 1 to {MAX_REPEATS}")), + } +} + +/// An error when `p` gives a spread other than pad for a gradient of `kind` that has no ends to +/// spread past (freeform). +fn spread_for(kind: GradientKind, p: &Value) -> Parsed<()> { + match spread_param(p)? { + Some(s) if !s.is_pad() && kind == GradientKind::Freeform => Err("`spread` applies to linear, radial, conic and diamond gradients".into()), + _ => Ok(()), + } +} + /// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d` /// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and /// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps @@ -271,6 +302,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { Some(v) => v.as_bool().ok_or("`dither` must be true or false")?, }; gp.gradient.method = method_param(g)?.unwrap_or_default(); + spread_for(kind, g)?; + gp.gradient.spread = spread_param(g)?.unwrap_or_default(); + gp.gradient.repeats = repeats_param(g)?.unwrap_or(1); gp.angle = f64_or(g, "angle", 0.0); gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; @@ -422,6 +456,13 @@ fn apply_gradient_edit_in_model( if let Some(m) = method_param(p)? { gp.gradient.method = m; } + spread_for(gp.gradient.kind, p)?; + if let Some(sp) = spread_param(p)? { + gp.gradient.spread = sp; + } + if let Some(n) = repeats_param(p)? { + gp.gradient.repeats = n; + } // Angle and aspect edits keep the focal point in its place in the extent ellipse. let before = gp.geom; if let Some(a) = p.get("angle").and_then(Value::as_f64) { @@ -516,6 +557,18 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } _ => std::borrow::Cow::Borrowed(p), }; + // A spread for selected gradients leaves a freeform default (which has no ends) as it is. + let spreads = matches!(spread_param(p), Ok(Some(sp)) if !sp.is_pad()); + let for_default = match for_default { + q if spreads && !ids.is_empty() && default_kind == Some(GradientKind::Freeform) => { + let mut q = q.into_owned(); + if let Some(o) = q.as_object_mut() { + o.remove("spread"); + } + std::borrow::Cow::Owned(q) + } + q => q, + }; let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index d27809fa78..29de2210ae 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -4,7 +4,7 @@ //! The built-in presets are the gradient swatches of the built-in swatch libraries //! ([`vectorcraft_color::libraries`]: the app's own computed palettes), each listed under its //! library's name. Saved presets are kept with the preferences ([`crate::Prefs::gradient_presets`]), -//! so every document has them. A preset is the colour ramp (type, stops, method, dither), not a +//! so every document has them. A preset is the color ramp (type, stops, method, dither, spread), not a //! placement: applying one edits the gradient in place, as `paint.editGradient` does. use serde::{Deserialize, Serialize}; @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient Presets", [], None, - "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", + "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?, spread?, repeats?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", always, list ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Apply Gradient Preset", [], None, - "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops and method, and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", + "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops, method, spread and repeats (a freeform preset's: the first three), and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", has_doc, apply ), @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Save Gradient Preset", [], None, - "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method and dither (stops linked to a swatch keep their colour, unlinked) → {name}", + "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method, dither, spread and repeats (stops linked to a swatch keep their colour, unlinked) → {name}", can_save, save ), @@ -90,12 +90,13 @@ fn builtin_presets() -> impl Iterator { }) } -/// Do `a` and `b` have the same colour ramp (type, method, dither and stops), swatch links aside? +/// Do `a` and `b` have the same color ramp (type, method, dither, spread as it paints and stops), +/// swatch links aside? fn same_ramp(a: &Gradient, b: &Gradient) -> bool { let stop = |x: &vectorcraft_color::GradientStop, y: &vectorcraft_color::GradientStop| { (x.offset, x.color, x.opacity, x.midpoint) == (y.offset, y.color, y.opacity, y.midpoint) }; - (a.kind, a.method, a.dither) == (b.kind, b.method, b.dither) + (a.kind, a.method, a.dither, a.painted_spread()) == (b.kind, b.method, b.dither, b.painted_spread()) && a.stops.len() == b.stops.len() && a.stops.iter().zip(&b.stops).all(|(x, y)| stop(x, y)) } @@ -215,6 +216,11 @@ fn apply(s: &mut Session, p: &Value) -> Result { 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())); + // 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())); + o.insert("repeats".into(), json!(g.repeat_count())); + } // Dither is a fill's (paint.editGradient refuses it on a stroke). if !stroke { o.insert("dither".into(), json!(g.dither)); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 593d325460..f93cdca7b9 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", + "{x?, y?, width?, height? (each more than 0 and at most 3,999,856 pt, as file.new takes them), name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result { ok() } -fn rect_from(p: &Value, default: Rect) -> Rect { +/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given. +fn rect_unclamped(p: &Value, default: Rect) -> Rect { let x = f64_or(p, "x", default.x0); let y = f64_or(p, "y", default.y0); - let w = f64_or(p, "width", default.width()).max(1.0); - let h = f64_or(p, "height", default.height()).max(1.0); + let w = f64_or(p, "width", default.width()); + let h = f64_or(p, "height", default.height()); Rect::new(x, y, x + w, y + h) } +/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it. +/// A side left out keeps `default`'s, even below 1 pt. +fn rect_from(p: &Value, default: Rect) -> Rect { + let r = rect_unclamped(p, default); + let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side }; + Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height())) +} + /// An artboard command's `color` param, its background color: None when absent, `Some(None)` for /// null (no color), else `Some(Some(color))`. fn artboard_color(p: &Value, cmd: &str) -> Result>> { @@ -926,6 +935,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { let guide_label = artboard_guide_label(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + if ["width", "height"].iter().filter_map(|k| p.get(*k).and_then(Value::as_f64)).any(|side| !super::newdoc::side_in_range(side)) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let r = match artboard_side(p, "artboard.new")? { Some(side) => { if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -939,8 +951,12 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { src + side.offset(src) } None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), - None => rect_from(p, def), + None => rect_unclamped(p, def), }; + // A size taken from another artboard (`side`, or width or height left out) keeps to the limit too. + if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e0bacc3859..ab6dd3e56a 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -28,7 +28,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{ + Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, +}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -68,7 +70,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), 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)}", + "{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), 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)}", has_doc, relink ), @@ -1195,6 +1197,83 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { }); } +/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without +/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not +/// allow names its file alone, without its relative path or the size and date kept of its file, +/// and each data set's image value and linked JavaScript file (`svg_scripts`) it does not allow +/// names its file alone too, while a JavaScript file given as a URL stays as it is. The folder Export +/// for Screens last wrote to is left out of the export settings when it is outside the read roots. +/// A link whose path is allowed but whose relative path leads outside the read roots loses its +/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited +/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored +/// brush entry that does not read as a brush is left out of the copy, since its links cannot be +/// checked, and a stored brush library that is not a list becomes an empty one. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> { + if crate::file_access::current().is_none() { + return Cow::Borrowed(d); + } + let doc_path = base.doc; + // A stored relative path is followed from the folder of the file the document was read from, + // and a link is also looked for from the document's own folder: it is shown only when both + // places it names are inside the read roots. + let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from)); + let mut d = d.clone(); + // The pattern being edited as it was before is checked as one more pattern, then put back. + let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); + let edited = original.is_some(); + d.patterns.extend(original); + // The art of each stored brush is checked as more layers, then put back. + let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default()); + let mut brushes: Vec = + stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect(); + let layers = d.layers.len(); + d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone()))); + d.update_links_where( + |l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden), + |l| { + if path_shown(&l.path, base.doc) { + l.relative = None; + } else { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + } + }, + ); + let mut checked = d.layers.split_off(layers).into_iter(); + if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { + e.original = Some(def); + } + if stored.is_some() { + // Each brush is written as it reads, so fields it does not read are left out too. + let mut kept = vec![]; + for b in &mut brushes { + for (slot, art) in b.art_mut().into_iter().zip(&mut checked) { + *slot = Arc::unwrap_or_clone(art); + } + kept.extend(serde_json::to_value(&*b).ok()); + } + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept)); + } + if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { + d.export_settings.remove("folder"); + } + let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path)); + if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) { + for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) { + if hidden(&*v) + && let DataValue::Image(path) = v + { + *path = LinkInfo::new(path.as_str()).name().to_string(); + } + } + } + let script_hidden = |f: &str| !path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url)); + for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f)) { + *f = LinkInfo::new(f.as_str()).name().to_string(); + } + Cow::Owned(d) +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { @@ -1337,7 +1416,9 @@ fn relink(s: &mut Session, p: &Value) -> Result { (true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), (true, true) => { let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?; - Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?) + // The document's path is not the client's: the error names it as the request may see it. + let no_folder = || fileio::naming_in_error(bad(C, format!("{doc} has no folder")), &doc); + Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?) } }; // An image or a placed document, with its link. @@ -1472,6 +1553,12 @@ fn relink(s: &mut Session, p: &Value) -> Result { not_found.dedup(); let mut out = json!({ "relinked": relinked, "notFound": not_found }); if copy_to.is_some() { + // The copies are in the document's folder, which the client did not give. + for pair in copied.iter_mut().chain(kept.iter_mut()) { + if let Some(to) = pair.get("to").and_then(Value::as_str).map(str::to_string) { + pair["to"] = json!(fileio::shown_path(&to)); + } + } out["copied"] = json!(copied); out["kept"] = json!(kept); } @@ -1521,9 +1608,14 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo use std::io::Write as _; let source = Path::new(from); let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - // Each name it takes is checked against the automation roots in force. + // Each name it takes is checked against the automation roots in force. The folder is the + // document's, so a refusal names it and the file as the request may see them. let create = |to: &Path| { - crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + let to_text = to.to_string_lossy(); + crate::file_access::check_write(&to_text).map_err(|e| { + let e = fileio::naming_in_error(EngineError::Other(e), &to_text); + std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()) + })?; vectorcraft_format::write_new_with(to, |f| f.write_all(bytes)) }; // A file there that automation may not read isn't taken for the same file. @@ -1534,7 +1626,10 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)), Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)), Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))), - Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + Err(e) => { + let dir = dir.to_string_lossy(); + Err(fileio::naming_in_error(EngineError::Other(format!("{dir}: {e}")), &dir)) + } } } diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index d47e81befd..a208d527fa 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -389,6 +389,39 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { }) } +pub(super) fn blend_set_easing(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.setEasing"; + let id = match crate::cmd::object_param(p, "id", C)? { + Some(id) => id, + None => *selected_of(s, is_blend).first().ok_or_else(|| bad(C, "select a blend or give `id`"))?, + }; + let doc = &s.doc()?.doc; + let Some(NodeKind::Blend { children, spec }) = doc.node(id).map(|n| &n.kind) else { return Err(bad(C, "not a blend")) }; + if !doc.is_editable(id) { + return Err(bad(C, "the object is hidden or locked")); + } + let ease = ease_param(p, C, spec.ease)?; + let color_shift = percent_param(p, C, "colorShift", spec.color_shift, -100.0)?; + // Easing moves the steps along a spine, so it needs one with a length to run along. + let easing = ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())); + let spined = spec.eases_along_spine() + && live::blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)).is_some_and(|sp| sp.length().is_finite() && sp.length() > 0.0); + if easing && !spined { + return Err(bad(C, "the blend has no spine")); + } + let changed = ease != spec.ease || color_shift != spec.color_shift; + if changed { + s.edit("Blend Options", |d, _| { + if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + spec.ease = ease; + spec.color_shift = color_shift; + } + Ok(()) + })?; + } + Ok(json!({ "id": id.0, "easingType": ease.kind.id(), "ramp": ease.ramp * 100.0, "colorShift": color_shift, "changed": changed })) +} + /// Spacing and orientation as `object.blend.options` takes them; the step count and distance /// not in use show their defaults. fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value { diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 1013c606c5..8e325c69be 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, - blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_reverse_stack, blend_set_easing, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -49,6 +49,15 @@ pub fn specs() -> Vec { always, blend_create_spine ), + cmd!( + "object.blend.setEasing", + "Blend Easing", + [], + None, + "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path); a blend made without a spine takes them once Replace Spine has given it one; out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", + has_doc, + blend_set_easing + ), cmd!( "object.blend.expand", "Expand", diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index bd15d748ca..58c7974460 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -241,8 +241,8 @@ impl DocSettings { if let Some(h) = length("height", self.units)? { self.height = h; } - if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE { - return Err(bad(cmd, "width/height must be positive and within the canvas")); + if !side_in_range(self.width) || !side_in_range(self.height) { + return Err(side_error(cmd, self.units)); } match p.get("orientation").and_then(Value::as_str) { None => {} @@ -359,8 +359,29 @@ impl DocSettings { } } -/// Largest artboard side `file.new` accepts, points. -const MAX_SIDE: f64 = 16383.0 * 10.0; +/// Largest artboard side `file.new` and `artboard.new` accept, points: the span of the canvas +/// ([`crate::MAX_COORD`] from the origin) less the largest bleed on both sides, 3,999,856 pt. +pub(crate) const MAX_SIDE: f64 = crate::MAX_COORD - 2.0 * vectorcraft_doc::setup::MAX_BLEED; + +/// Is `side` an artboard side `file.new` and `artboard.new` accept: more than 0 and at most [`MAX_SIDE`]? +pub(crate) fn side_in_range(side: f64) -> bool { + side > 0.0 && side <= MAX_SIDE +} + +/// The error of an artboard side outside [`side_in_range`]: the limit in points, and in `units` as +/// well when they aren't points, rounded down to the decimals [`Unit::number`] shows so the value +/// shown is one the commands take. +pub(crate) fn side_error(cmd: &str, units: Unit) -> crate::EngineError { + let limit = match units { + Unit::Points => format!("{MAX_SIDE} pt"), + u => { + let step = if matches!(u, Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 1e3 } else { 1e4 }; + let shown = (u.from_pt(MAX_SIDE) * step).floor() / step; + format!("{MAX_SIDE} pt ({} {})", u.number(u.to_pt(shown)), u.suffix()) + } + }; + bad(cmd, format!("width and height must be more than 0 and at most {limit}")) +} /// Largest artboard spacing, points. const MAX_SPACING: f64 = 1.0e5; diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 3dc1c37dad..0a427e3ea0 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -133,16 +133,16 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, - // objects with a butt-capped stroke along a segment at a slant, whose margins change with the - // segment's direction, and type measured by its glyphs, are measured as drawn after each scale - // tried instead ([`refit_drawn`]). + // objects with a stroke that stops at the corners of a segment at a slant (butt caps, bevel and + // miter joins), whose margins change with the segment's direction, and type measured by its + // glyphs, are measured as drawn after each scale tried instead ([`refit_drawn`]). let to_local = frame.to_doc().inverse(); let drawn = || { by_glyphs || ids .iter() .filter_map(|id| d.node(*id)) - .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_stroke_corners(&symbols, to_local)) }; // Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink // included), so the scales above are exact and nothing is refitted. diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b199f9a356..1aeba3ad30 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; +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::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; @@ -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), 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); 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 (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); 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) ), @@ -718,7 +718,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: } /// The message for a process color on a grayscale image with an alpha channel. -const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; +pub(super) const PROCESS_ON_TRANSPARENCY: &str = + "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; /// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch /// of `d`, black or white? @@ -737,11 +738,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { let mut refused = false; let mut visit = |n: &Node| { vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) - && !refused - && !colorizes_transparency(d, paint) - && d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) - { + if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) { refused = true; } }) @@ -761,6 +758,13 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color +/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel. +pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option { + let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None }; + if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) } +} + /// Is `e` the refusal of [`check_colorized`]? pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { e.to_string().contains(PROCESS_ON_TRANSPARENCY) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 83a0655284..d541e8c05d 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -467,6 +467,9 @@ fn list_level_param(p: &Value, cmd: &str) -> Result> { fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; + if p.get("paragraphIndents").is_some() { + return set_paragraph_indents(s, p); + } let ids = super::typecmd::text_targets(s, p, C)?; check_switches( p, @@ -483,6 +486,28 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "hyphenateCapitalized", "romanHangingPunctuation", "koreanLineBreakAnywhere", + "bunriKinshi", + ], + C, + )?; + // Each value that `num` reads below must be a number or null. + super::check_numbers( + p, + &[ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", ], C, )?; @@ -536,6 +561,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "mojikumi", "kinsoku", "kinsokuType", + "bunriKinshi", "direction", "leadingModel", "charAlign", @@ -557,7 +583,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "kashida", "composer", ]; - if !keys.iter().any(|k| p.get(*k).is_some()) { + // A null value leaves its attribute as it is, as an omitted one does. + if !keys.iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { return Err(bad(C, "nothing to change")); } // The anti-aliasing method belongs to the whole type object; each frame keeps its own method. @@ -587,7 +614,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_up_down = super::textedit::tcy_offset_param(p, "tcyUpDown", C)?; let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let mojikumi = match p.get("mojikumi") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Mojikumi::None, Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf, @@ -595,7 +622,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kinsoku = match p.get("kinsoku") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Kinsoku::None, Some("hard") => vectorcraft_doc::Kinsoku::Hard, @@ -604,7 +631,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kinsoku_type = match p.get("kinsokuType") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("pushInFirst") => vectorcraft_doc::KinsokuShoriType::PushInFirst, Some("pushOutFirst") => vectorcraft_doc::KinsokuShoriType::PushOutFirst, @@ -613,7 +640,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let direction = match p.get("direction") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("auto") => None, Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight), @@ -622,7 +649,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let leading_model = match p.get("leadingModel") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("romanBaseline") => vectorcraft_doc::LeadingModel::RomanBaseline, Some("emBoxTop") => vectorcraft_doc::LeadingModel::EmBoxTop, @@ -630,7 +657,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let burasagari = match p.get("burasagari") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Burasagari::None, Some("standard") => vectorcraft_doc::Burasagari::Standard, @@ -639,7 +666,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kashida = match p.get("kashida") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::text::Kashida::None, Some("short") => vectorcraft_doc::text::Kashida::Short, @@ -670,7 +697,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); let single_word = match p.get("singleWordJustification") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("fullJustify") => SingleWordJustify::FullJustify, Some("alignLeft") => SingleWordJustify::AlignLeft, @@ -680,7 +707,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let auto_leading = match p.get("autoLeading") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?), }; // A paragraph whose Minimum, Desired and Maximum would no longer be in order. @@ -787,12 +814,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result { l.level = list_level.unwrap_or(l.level); } }; - s.edit("Character", |d, _| { + // The change is made on a copy first: a call whose values are the ones the text already has + // records no edit, undo step or revision. Only the type objects and graph label styles the call + // changes are compared, not the whole document. + super::variablecmds::settle_typing(s)?; + let mut doc = (*s.doc()?.doc).clone(); + let mut changed = false; + { + let d = &mut doc; // The paragraph change, for the graph labels to keep. let mut para_change = None; let out_of_order_error = |name: &str| bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")); for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let before = t.clone(); let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; let local = |v: Option| v.map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose(); @@ -869,6 +904,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = kinsoku_type { para.kinsoku_type = v; } + if let Some(v) = flag("bunriKinshi") { + para.bunri_kinshi = v; + } if let Some(v) = direction { para.direction = v; } @@ -896,16 +934,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result { return Err(out_of_order_error(name)); } super::typecmd::refresh_bounds(t); + changed |= *t != before; para_change = Some(paras); } // Graph labels keep a change made to all of their text through the graph being drawn again. if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); - super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); if let Some(paras) = para_change { // The kept attributes are checked too: they may differ from the labels'. - super::graph::capture_label_paragraphs(d, &ids, &|para| { + changed |= super::graph::capture_label_paragraphs(d, &ids, &|para| { let mut out_of_order = None; paras(para, &mut out_of_order); out_of_order.map_or(Ok(()), |name| Err(out_of_order_error(name))) @@ -915,14 +954,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = anti_alias { for id in &ids { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + changed |= t.anti_alias != v; t.anti_alias = v; } } // Graph labels keep the method through the graph being drawn again. - super::graph::capture_label_anti_alias(d, &ids, v); + changed |= super::graph::capture_label_anti_alias(d, &ids, v); } - Ok(()) - })?; + } + if changed { + s.edit("Character", |d, _| { + *d = doc; + Ok(()) + })?; + } let mut result = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); if let Some(kerning) = p.get("kerning") { result["kerning"] = kerning.clone(); @@ -930,6 +975,126 @@ fn set_format(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// The most entries `paragraphIndents` takes in one `text.setFormat` call. +pub(crate) const MAX_PARAGRAPH_INDENTS: usize = 100_000; + +/// The indents one `paragraphIndents` entry gives its paragraph. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ParagraphIndent { + left: Option, + first_line: Option, +} + +/// A type object that `paragraphIndents` names: the bytes where its paragraphs start and the +/// indents its entries give them, by paragraph index. +struct IndentedFrame { + id: NodeId, + starts: Vec, + changes: Vec<(usize, ParagraphIndent)>, +} + +/// `text.setFormat` with `paragraphIndents`: each entry names one paragraph of a type object, by +/// the object and the byte where the paragraph starts, and sets its left and first-line indents. +/// Every entry is checked against the document before anything changes. Each type object's +/// paragraph styles are rebuilt once and the whole change is one edit, so a threaded story flows +/// again once however many of its paragraphs change. When no entry changes its paragraph, nothing +/// is edited. +fn set_paragraph_indents(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setFormat"; + const KEY: &str = "paragraphIndents"; + if let Some(other) = p.as_object().and_then(|o| o.keys().find(|k| *k != KEY)) { + return Err(bad(C, format!("`{KEY}` takes no `{other}` beside it"))); + } + let entries = p.get(KEY).and_then(Value::as_array).ok_or_else(|| bad(C, format!("`{KEY}` must be a list")))?; + if entries.is_empty() { + return Err(bad(C, format!("`{KEY}` is empty"))); + } + if entries.len() > MAX_PARAGRAPH_INDENTS { + return Err(bad(C, format!("`{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {})", entries.len()))); + } + let d = &s.doc()?.doc; + // The type objects in the order they first appear. + let mut frames: Vec = vec![]; + let mut frame_at: std::collections::HashMap = std::collections::HashMap::new(); + let mut given: std::collections::HashMap<(NodeId, usize), ParagraphIndent> = std::collections::HashMap::new(); + for (n, e) in entries.iter().enumerate() { + let e = e.as_object().ok_or_else(|| bad(C, format!("`{KEY}` entry {n} must be an object")))?; + if let Some(k) = e.keys().find(|k| !["id", "start", "leftIndent", "firstLineIndent"].contains(&k.as_str())) { + return Err(bad(C, format!("`{KEY}` entry {n}: unknown key `{k}`"))); + } + let indent = |k: &str| -> Result> { + match e.get(k) { + None => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && x.abs() <= MAX_PARA => Ok(Some(x)), + _ => Err(bad(C, format!("`{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt"))), + }, + } + }; + let change = ParagraphIndent { left: indent("leftIndent")?, first_line: indent("firstLineIndent")? }; + if change.left.is_none() && change.first_line.is_none() { + return Err(bad(C, format!("`{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent`"))); + } + let id = NodeId(e.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `id` must be an object id")))?); + let start = index(e.get("start").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `start` must be a byte offset")))?); + let k = match frame_at.get(&id) { + Some(k) => *k, + None => { + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { + return Err(bad(C, format!("`{KEY}` entry {n}: {} is not a type object", id.0))); + }; + let plain = t.plain_text(); + let mut starts = vec![0]; + starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); + frames.push(IndentedFrame { id, starts, changes: vec![] }); + frame_at.insert(id, frames.len() - 1); + frames.len() - 1 + } + }; + let Some(IndentedFrame { starts, changes, .. }) = frames.get_mut(k) else { continue }; + let para = starts.binary_search(&start).map_err(|_| bad(C, format!("`{KEY}` entry {n}: no paragraph of {} starts at byte {start}", id.0)))?; + match given.insert((id, para), change) { + None => changes.push((para, change)), + Some(was) if was == change => {} + Some(_) => { + return Err(bad( + C, + format!("`{KEY}` entry {n} gives the paragraph at byte {start} of {} other indents than an entry before it", id.0), + )); + } + } + } + let ids = json!({ "ids": frames.iter().map(|f| f.id.0).collect::>() }); + // Only the type objects with a paragraph whose indents change are edited. + let differs = |t: &vectorcraft_doc::TextObject, i: usize, c: &ParagraphIndent| { + let para = t.para_at(i); + c.left.is_some_and(|v| v != para.left_indent) || c.first_line.is_some_and(|v| v != para.first_line_indent) + }; + frames.retain(|f| match d.node(f.id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => f.changes.iter().any(|(i, c)| differs(t, *i, c)), + _ => false, + }); + if frames.is_empty() { + return Ok(ids); + } + s.edit("Character", |d, _| { + for f in &frames { + let Some(NodeKind::Text(t)) = d.node_mut(f.id).map(|n| &mut n.kind) else { continue }; + let mut styles = t.paragraph_styles(); + for (i, c) in &f.changes { + if let Some(para) = styles.get_mut(*i) { + para.left_indent = c.left.unwrap_or(para.left_indent); + para.first_line_indent = c.first_line.unwrap_or(para.first_line_indent); + } + } + t.set_paragraph_styles(styles); + super::typecmd::refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids) +} + /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it /// and the numbered paragraphs the frame counts before it at each list level. diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5b9e25d354..c617c4de8e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke( }; let art = d.reid(&art); let mut art = with_transparency(d, art, st.opacity, st.blend); - let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect(); + let raster = vectorcraft_render::effects::without_geometry(&st.effects); if !raster.is_empty() { if !art.appearance.effects.is_empty() { art = Node::group(d.alloc_id(), vec![Arc::new(art)]); @@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke( /// geometry effects leave nothing to paint, an empty group takes the path's place. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } @@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = moved; - plain.effects_mut().retain(|e| !is_geometry(&e.id)); + *plain.effects_mut() = without_geometry(plain.effects()); parts.push(styled_copy(d, &shape, vec![plain])); } AppearanceItem::Stroke(st) if outlinable(st) => { @@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = st.clone(); - plain.effects.retain(|e| !is_geometry(&e.id)); + plain.effects = without_geometry(&st.effects); let stroked = match path.bounds() { Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)), _ => path.clone(), diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index b151adcea2..f4b91edd9b 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -102,7 +102,53 @@ const FILE_WORDS: &[&str] = &[ ]; /// Param keys that name a file or a folder. -const FILE_KEYS: &[&str] = &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url"]; +const FILE_KEYS: &[&str] = + &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url", "diffPath"]; + +/// The keys in [`FILE_KEYS`] that name a folder. +const FOLDER_KEYS: &[&str] = &["folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder"]; + +/// What a param key that names a place on disk names there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Location { + /// A file (or a list of files). + File, + /// A folder (or a list of folders). + Folder, +} + +/// What `key` names on disk when a command takes it ([`FILE_KEYS`]), or `None` when it never +/// names a file or a folder. +pub fn key_location(key: &str) -> Option { + if FOLDER_KEYS.contains(&key) { + Some(Location::Folder) + } else if FILE_KEYS.contains(&key) { + Some(Location::File) + } else { + None + } +} + +/// What `key` names on disk in command `spec`: [`key_location`], or `None` when [`NOT_FILES`] +/// names it for `spec` (it names part of the document there). +pub fn location_in(spec: &CommandSpec, key: &str) -> Option { + if NOT_FILES.contains(&(spec.id, key)) { None } else { key_location(key) } +} + +/// The param keys of `spec` that name a file or a folder, with what they name: the keys in +/// [`FILE_KEYS`] that its params description names ([`super::params::doc_keys`]), with those of the +/// commands its `…` entries name ([`super::params::takes_key`]), and its schema's params, less those +/// [`NOT_FILES`] names for it. Sorted, each key once. +pub fn location_keys(spec: &CommandSpec) -> Vec<(&'static str, Location)> { + let described = super::params::doc_keys(spec.params).map(|p| p.keys).unwrap_or_default(); + let in_schema = spec.schema.map(|s| s.params.iter().map(|p| p.name)).into_iter().flatten(); + let taken = FILE_KEYS.iter().copied().filter(|k| super::params::takes_key(spec.params, k)); + let mut keys: Vec<(&'static str, Location)> = + described.into_iter().chain(in_schema).chain(taken).filter_map(|k| Some((k, location_in(spec, k)?))).collect(); + keys.sort_unstable_by_key(|(k, _)| *k); + keys.dedup(); + keys +} /// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of /// its art, a setting, an attribute it stores or a result field), not a file. @@ -119,8 +165,14 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("attributes.set", "url"), ("svgInteractivity.info", "files"), ("addon.cleanup.rejoin", "paths"), + ("object.slice.options", "url"), + ("document.exportForScreens", "folders"), ]; +/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there, +/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`. +const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])]; + /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], /// [`FILE_WORDS`])? fn denied_id(id: &str) -> bool { @@ -184,20 +236,32 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool { /// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth /// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`] -/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep -/// arguments can't overflow the thread's. +/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for +/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the +/// thread's. fn names_file(id: &str, args: &Value) -> bool { - let mut stack = vec![(args, true)]; - while let Some((v, top)) = stack.pop() { + // Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list + // still to follow below it. + let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)]; + while let Some((v, top, trail)) = stack.pop() { match v { - Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))), + Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))), Value::Object(o) => { for (k, x) in o { let k = k.as_str(); - if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) { + let rest = if top { + NESTED_NOT_FILES + .iter() + .find(|(c, keys)| *c == id && keys.first() == Some(&k)) + .map(|(_, keys)| keys.get(1..).unwrap_or_default()) + } else { + trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default()) + }; + let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty); + if FILE_KEYS.contains(&k) && !document { return true; } - stack.push((x, false)); + stack.push((x, false, rest.filter(|keys| !keys.is_empty()))); } } _ => {} @@ -466,7 +530,8 @@ mod tests { } for (id, key) in NOT_FILES { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; - assert!(names_key(spec.params, key), "{id} has no `{key}` param"); + let described = crate::cmd::params::doc_keys(spec.params).is_ok_and(|p| p.keys.contains(key)); + assert!(names_key(spec.params, key) || described, "{id} has no `{key}` param"); } let leaks: Vec<&str> = specs .iter() @@ -536,6 +601,16 @@ mod tests { assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); + // A custom arrowhead's outline in stroke.set, only where stroke.set takes one. + let outline = json!({"subpaths": []}); + assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}}))); + assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline, "file": "/tmp/x"}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"path": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"path": "/tmp/x"}}))); + assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]}))); + assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}}))); // Deep nesting is walked without recursion. let mut deep = json!({"file": "/tmp/x"}); for _ in 0..10_000 { @@ -565,6 +640,40 @@ mod tests { read.unwrap(); } + /// [`location_keys`] names every key in [`FILE_KEYS`] that a command's params description or + /// schema names, in every way a description writes one (`path | name`, `folder? (…)`), less + /// those [`NOT_FILES`] names for it; folder keys name folders. No command a plug-in may call + /// has one. + #[test] + fn location_keys_come_from_the_command_catalog() { + let find = |id: &str| crate::cmd::find_command(id).unwrap_or_else(|| panic!("no command {id}")); + let keys = |id: &str| location_keys(find(id)); + assert!(keys("document.open").contains(&("path", Location::File)), "{:?}", keys("document.open")); + assert!(keys("document.compare").contains(&("diffPath", Location::File)), "{:?}", keys("document.compare")); + assert!(keys("addon.document.saveTimestampedCopy").contains(&("folder", Location::Folder))); + let relink = keys("links.relink"); + for k in [("path", Location::File), ("paths", Location::File), ("folder", Location::Folder)] { + assert!(relink.contains(&k), "{relink:?}"); + } + assert!(keys("action.batch").contains(&("destinationFolder", Location::Folder))); + for (id, key) in NOT_FILES { + assert!(!keys(id).iter().any(|(k, _)| k == key), "{id} `{key}` names a file"); + } + for spec in crate::cmd::command_specs() { + let found = location_keys(spec); + let described = crate::cmd::params::doc_keys(spec.params).map(|p| p.keys).unwrap_or_default(); + let in_schema = spec.schema.map(|s| s.params.iter().map(|p| p.name).collect::>()).unwrap_or_default(); + for k in described.into_iter().chain(in_schema).filter(|k| FILE_KEYS.contains(k) && !NOT_FILES.contains(&(spec.id, *k))) { + assert!(found.iter().any(|(f, _)| *f == k), "{}: `{k}` is missing from {found:?}", spec.id); + } + assert!(found.iter().all(|(k, l)| (*l == Location::Folder) == FOLDER_KEYS.contains(k)), "{}: {found:?}", spec.id); + assert!(denied(spec).is_some() || found.is_empty(), "{} names {found:?} but a plug-in may call it", spec.id); + } + assert_eq!(key_location("url"), Some(Location::File)); + assert_eq!(key_location("sourceFolder"), Some(Location::Folder)); + assert_eq!(key_location("pathData"), None); + } + #[test] fn keys_are_found_as_whole_keys() { assert!(names_key("{path: a .wasm file}", "path")); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 060791972f..f25dfd4a35 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -33,6 +33,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.swatchLegend", "addon.contour.set", "addon.cutting.bridges", + "addon.cutting.weed", "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", @@ -241,6 +242,7 @@ pub(super) const ALLOWED: &[&str] = &[ "object.blend.replaceSpine", "object.blend.reverseFrontToBack", "object.blend.reverseSpine", + "object.blend.setEasing", "object.blend.spine.addAnchor", "object.blend.spine.convertAnchor", "object.blend.spine.moveAnchor", @@ -687,5 +689,6 @@ pub(super) const DENIED: &[&str] = &[ "object.blend.options", "object.liquify", "object.pathfinder.options", + "stroke.arrowheads.edit", "type.compositeFonts.set", ]; diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index af09b032da..c605cd39f3 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -156,6 +156,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), + p!("boundingBoxWhileMovingAndDrawing", "General", "Options", "Show Bounding Box While Moving and Drawing", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), p!( diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 566a16dbfd..8f5f532c51 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -645,8 +645,8 @@ pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::re export_pdf_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features -/// approximated or left out). +/// Like [`export_pdf`], also returning the writer's warnings (features approximated or left out) +/// and its notes on the options (options not applied yet). pub fn export_pdf_with_report( doc: &Document, opts: &vectorcraft_pdf::PdfOptions, diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index f0087595dd..c1c7f789fe 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -1163,6 +1163,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, take: bool) -> Owner { +/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the +/// caller already holds. +fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool, held: &[&str]) -> Owner { if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { return Owner::Mine; } match store.lock(area) { Ok(Lock::Held(h)) => { // Copies an app left here when it had to leave the area stay its own while it runs. - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } hold.push(h); @@ -1294,7 +1296,7 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) { return Owner::Running; } - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } // Best effort: without it another app might offer the copies too, which loses nothing. @@ -1310,12 +1312,14 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< /// `/keeps-` ([`mark_left`]): this session, or another app that is running. `None`: /// no app that runs keeps them. An entry counts only in an area this account can lock or tell is /// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be -/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing). -fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option { +/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds +/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now +/// keeps them (a copy kept loses nothing). +fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option { let Ok(names) = store.list() else { return Some(Owner::Running) }; let mark = format!("/{KEEPS}{area}"); let mut who = None; - for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) { + for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) { match holder(s, store, k) { Owner::Mine => return Some(Owner::Mine), Owner::Running => who = Some(Owner::Running), @@ -1377,7 +1381,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { let mut hold = vec![]; - let who = owner(s, store.as_ref(), &area, &mut hold, false); + let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]); let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect(); for f in &files { let meta = read_meta(store.as_ref(), f); @@ -1417,10 +1421,11 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> } let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c); let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); - let mut out = vec![]; + let mut out: Vec = vec![]; for area in areas { let mut hold = vec![]; - match (owner(s, store, area, &mut hold, true), file) { + let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect(); + match (owner(s, store, area, &mut hold, true, &held), file) { (Owner::Gone, _) => { let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect(); out.push((area.to_string(), files, hold)); diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 44a14dbe34..f5edf1f91d 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -297,7 +297,8 @@ fn list_installed(s: &mut Session, _: &Value) -> Result { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); }; let folder = root.to_string_lossy(); - crate::file_access::check_read(&folder).map_err(EngineError::Other)?; + // The Scripts folder is the app's: a refusal names it as the request may see it. + crate::file_access::check_read(&folder).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), &folder))?; let listing = script::folder::list(root); let scripts: Vec = listing .scripts() diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 44ec938bc2..228251fa13 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -2,7 +2,9 @@ //! art retained underneath. Recipes select topology regions; source edits re-evaluate them in //! the same undo transaction. Rendering and exporters only see ordinary groups and paths. use super::pathops::Beside; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param}; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, number_or, object_param, point_param, +}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -34,7 +36,7 @@ pub fn specs() -> Vec { "Select Shaper Face", [], None, - "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", + "{point: [x,y], source?: bool, tolerance?: pt (3)} click selects a group, then a face; with the group or one of its faces selected, a click within tolerance of an original shape's outline enters construction isolation and selects that original; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", has_doc, select ), @@ -734,16 +736,50 @@ fn group_at(d: &Document, point: Point, construction: bool, within: Option Option<(NodeId, NodeId)> { + let mut hit = None; + let editable = d.editable(); + d.walk(|n| { + if n.extras().shaper.is_none() || !editable(n.id) { + return; + } + let picked = selection.contains(n.id) || selection.objects.iter().any(|id| d.parent_of(*id) == Some(n.id)); + let on_outline = + |s: &&Arc| b::node_outline(s).is_some_and(|(path, _)| path.nearest(point).is_some_and(|(_, _, _, _, dist)| dist <= tolerance)); + let source = if picked { sources(n).iter().rev().filter(|s| s.visible && !s.locked).find(on_outline).map(|s| s.id) } else { None }; + if let Some(source) = source { + hit = Some((n.id, source)); + } else if n.geometric_bounds().is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) { + hit = None; + } + }); + hit +} fn select(s: &mut Session, p: &Value) -> Result { let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?; let st = s.doc()?; let source = bool_or(p, "source", false); + let tolerance = number_or(p, "tolerance", 3.0, "shaper.select")?; + if !tolerance.is_finite() || !(0.0..=1000.0).contains(&tolerance) { + return Err(bad("shaper.select", "invalid tolerance")); + } // In Construction mode a source pick stays in the group whose source shapes are isolated. let isolated = st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id))); - let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) }; + // With a group or one of its faces selected, a click on one of its original shapes' outlines + // enters Construction mode with the topmost such shape selected, wherever the outline lies. + let outline = if source { None } else { outline_at(&st.doc, &st.selection, point, tolerance) }; + let Some(group) = outline.map(|(g, _)| g).or_else(|| group_at(&st.doc, point, source, isolated.filter(|_| source))) else { + return Ok(json!({"id": null})); + }; let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?; - let target = if source { + let source = source || outline.is_some(); + let target = if let Some((_, id)) = outline { + id + } else if source { sources(node) .iter() .rev() diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 4153fedd51..fe846ad8af 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -4,8 +4,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile, + ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, + Unit, WidthProfile, }; +use vectorcraft_geom::PathData; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -20,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -242,6 +244,34 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(Value::String(n)) if n == "none" => Ok(Some(None)), + // A custom head by name, from the arrowheads file (stroke.arrowheads.list). + Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => { + let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) }; + let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?; + Ok(Some(Some(Arrowhead::Custom(head)))) + } + // A custom head with its art: an outline the head would drop is refused, not drawn empty. + Some(Value::Object(o)) if o.contains_key("Custom") => { + let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null); + let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?; + // The region the outline fills (non-zero rule), down to slivers a millionth of a + // head unit thin. Measured only once `fits` has bounded the anchors. + let fills = |p: &PathData| { + let area = vectorcraft_pathops::unite_all_with(&[(p, vectorcraft_geom::FillRule::NonZero)], 1e-6); + area.subpaths.iter().map(|sp| sp.area().abs()).sum::() > 1e-12 + }; + if !CustomArrowhead::fits(&path) || !fills(&path) { + let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS); + return Err(bad( + C, + format!( + "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip" + ), + )); + } + let head = p.get(k).cloned().unwrap_or(Value::Null); + serde_json::from_value::(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}"))) + } Some(v) => serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))), } }; @@ -258,11 +288,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let set = |st: &mut StrokeLayer| { change.apply(st); - if let Some(a) = sa { - st.start_arrow = a; + if let Some(a) = &sa { + st.start_arrow = a.clone(); } - if let Some(a) = ea { - st.end_arrow = a; + if let Some(a) = &ea { + st.end_arrow = a.clone(); } if let Some(a) = arrow_align { st.arrow_align = a; diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index c809d58090..751313957f 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -393,11 +393,12 @@ fn float_key(v: f32) -> Option { /// keys. type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>); -/// A gradient's kind, method, dither and stops, as keys. -type GradientKey<'a> = (u8, u8, bool, Vec>); +/// A gradient's kind, method, dither, spread and count as it paints them, and stops, as keys. +type GradientKey<'a> = (u8, u8, bool, (vectorcraft_color::GradientSpread, u32), Vec>); -/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when -/// any of its numbers is NaN, so the gradient equals no gradient. +/// A key that is equal for two gradients exactly when the gradients are equal (`==`), apart from +/// a count that a gradient which pads does not use ([`Gradient::painted_spread`]); `None` when any +/// of its numbers is NaN, so the gradient equals no gradient. fn gradient_key(g: &Gradient) -> Option> { let kind = match g.kind { GradientKind::Linear => 0, @@ -410,7 +411,7 @@ fn gradient_key(g: &Gradient) -> Option> { GradientMethod::Classic => 0, GradientMethod::Perceptual => 1, }; - Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::>()?)) + Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::>()?)) } /// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 45320066cf..8101cfff97 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Copy to User Defined", ["Window", "Swatch Libraries"], None, - "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}", + "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}", has_user_folder, copy_to_user ), @@ -368,7 +368,9 @@ impl Libraries { .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { Some(p) => { - // Outside the request's read roots: the error names no path. + // The file's bytes are read: outside the request's read roots, the error names no + // path. A document's swatches (the arm below) are written from the loaded library, + // and the document is not read again. if !readable(&p) { return Err(bad(cmd, "the library's file is outside the read roots")); } @@ -469,19 +471,34 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } +/// The file name a concealed library or path is shown by: the last part of `path` as this platform +/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute +/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one +/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part. +pub fn concealed_file_name(path: &str) -> String { + let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten(); + native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned()) +} + /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without -/// roots in force. +/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is +/// never readable under a read root. fn readable(path: &str) -> bool { - crate::file_access::check_read(path).is_ok() + !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok() } /// The loaded library ids (`loaded/`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/`, as a library loaded without a path is -/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then -/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free -/// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. The -/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows -/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none. +/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute +/// path counts as one the request may not read. The ids are those of the +/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers +/// to). When another id already is or shows that id, the next free +/// one of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two. A library +/// keeps the id it was first shown under for the rest of the session, so an id a client was given +/// finds the same library after another one with the same file name is loaded; only a library that +/// shows that id itself (one loaded later without a file under that name) moves it to the next free +/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of +/// their full ids, then `others`, in the same order. Without roots in force, none. pub fn concealed_library_ids(s: &Session, others: impl IntoIterator) -> Vec<(String, String)> { if crate::file_access::current().is_none() { return vec![]; @@ -493,7 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator hidden.push((id, name)), _ => { @@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator = assigned.values().cloned().collect(); hidden .into_iter() - .map(|(id, name)| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); + .zip(kept) + .map(|((id, name), kept)| { + let shown = kept.unwrap_or_else(|| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) || given.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + given.insert(shown.clone()); + assigned.insert(id.clone(), shown.clone()); + shown + }); (id, shown) }) .collect() @@ -746,8 +779,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result { "user" => (info, lib, false), _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), }; - let path = s.swatch_libraries.shown_path(&info.id); - Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) + let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied}); + // As the saves with `user: true`: no `path` when the request may not read the file. + if let Some(path) = s.swatch_libraries.shown_path(&info.id) { + out["path"] = json!(path); + } + Ok(out) } /// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index edfab850f5..eae92d87af 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -58,7 +58,7 @@ pub fn specs() -> Vec { "Glyphs", [], None, - "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it; its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of its alternates, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}", + "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it, a glyph no character maps and no feature reaches whose name is not another letter's (`a.salt` for a), its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of those, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}", has_doc, set_glyph_alternate ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?: pt 0.1..1296 (outside it: an error), font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -466,6 +466,8 @@ pub(crate) struct CharChange { language: Option>, font_axes: Option, font_height_reference: Option, + /// Take away the glyph choice ([`CharStyle::glyph_alternate`]) of the characters (`glyphAlternate: null`). + clear_glyph_alternate: bool, } /// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` @@ -567,7 +569,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) { } } -/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). +/// `features: ["dlig", "-liga", "salt=2", …]` → the canonical tag list (differences from the +/// defaults; `salt=1` is written `salt`, `salt=0` and `-salt` leave it out). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; let not_tags = || bad(cmd, "features must be a list of tags"); @@ -576,7 +579,8 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> return Err(bad( cmd, format!( - "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off)" + "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", + vectorcraft_text::MAX_FEATURE_VALUE ), )); } @@ -586,9 +590,9 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> /// The most entries a `featureChanges` list may have. const MAX_FEATURE_CHANGES: usize = 256; -/// A feature tag without its `-` or `+` prefix. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn feature_name(tag: &str) -> &str { - tag.strip_prefix(['-', '+']).unwrap_or(tag) + vectorcraft_text::OtFeatures::tag_name(tag) } /// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of @@ -713,7 +717,7 @@ impl CharChange { let c = Self { font: text_param(p, "font", cmd)?.map(str::to_string), style: text_param(p, "style", cmd)?.map(str::to_string), - size: num("size"), + size: num("size").map(|v| super::typecmd::given_type_size(v, cmd)).transpose()?, leading: auto_or_num(p, "leading", cmd)?, tracking: num("tracking"), kerning: match p.get("kerning") { @@ -756,10 +760,14 @@ impl CharChange { language: language_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, + clear_glyph_alternate: match p.get("glyphAlternate") { + None => false, + Some(Value::Null) => true, + Some(_) => { + return Err(bad(cmd, "`glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one)")); + } + }, }; - if c.size.is_some_and(|v| v <= 0.0) { - return Err(bad(cmd, "size must be positive")); - } Ok(c) } @@ -809,6 +817,7 @@ impl CharChange { && self.language.is_none() && self.font_axes.is_none() && self.font_height_reference.is_none() + && !self.clear_glyph_alternate } pub(crate) fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { @@ -951,6 +960,9 @@ impl CharChange { if let Some(v) = &self.language { st.language.clone_from(v); } + if self.clear_glyph_alternate { + st.glyph_alternate = None; + } } } @@ -1269,7 +1281,9 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { }; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; - let default = face.glyph_for(ch); + let db = vectorcraft_text::FontDb::global(); + let context = std::cell::OnceCell::new(); + let contextual = || context.get_or_init(|| vectorcraft_text::alternates_in_context(db, &t, bytes.start)); let holder = t .runs .iter() @@ -1290,6 +1304,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let mut probe = t.clone(); edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); let probe_lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); + // Choices belong to the supplying face, including fallback for a missing grapheme. When the + // character overflows, the contextual probe finds that face in an unconstrained frame. + let face = probe_lay + .glyphs + .iter() + .find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)) + .and_then(|g| db.face_by_id(g.font_id)) + .or_else(|| contextual().as_ref().and_then(|f| db.face_by_id(f.font_id))) + .unwrap_or(face); + let default = face.glyph_for(ch); let unchosen = holder .as_ref() .filter(|_| !styled) @@ -1297,7 +1321,14 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { None => None, - Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), + Some(g) + if g == default + || face.alternates(ch).iter().any(|a| a.gid == g) + || face.is_unlisted_alternate(ch, g) + || contextual().as_ref().is_some_and(|f| f.font_id == face.id() && f.alternates.iter().any(|a| a.gid == g)) => + { + Some(face.choice(ch, g)) + } Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), }; // Without a choice, the glyph drawn in whatever face draws the character, as @@ -1610,7 +1641,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["size"], C)?; check_switches(p, &["vertical", "placeholder"], C)?; super::color_param(p, "color", C)?; - let style = super::create::new_type_style(s, p); + let style = super::create::new_type_style(s, p, C)?; let text = str_param(p, "text").unwrap_or("").to_string(); let start = match point_param(p, "at") { Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0), diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 40d8af22ad..13c2e9e05b 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), ("kinsoku_type", json!(v.kinsoku_type)), + ("bunri_kinshi", json!(v.bunri_kinshi)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 77ecd9d786..74544058cf 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -309,12 +309,12 @@ fn character_style_color_and_features_reach_text_and_follow_swatch_edits() { // The command takes the color of the link at the tint, whatever color is sent. tinted["tint"] = json!(0.4); let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false, - "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]}); + "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]}); s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap(); let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap(); assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false)); - assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]); + assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]); let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone(); assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap()); let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); @@ -355,6 +355,8 @@ fn character_style_color_and_features_are_checked() { (json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"), (json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"), (json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"), + (json!({"features": ["salt=x"]}), "unknown OpenType feature `salt=x`"), + (json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"), (json!({"features": many}), "features lists at most 256 tags"), (json!({"fill": {"type": "solid"}}), "missing field"), ] { diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 48a1e46fbc..b6ea41df3e 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -1679,3 +1679,164 @@ fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { let cached = t.cached_bounds.unwrap(); assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); } + +/// Twelve paragraphs that wrap, threaded through three frames, the first two of which end inside +/// a paragraph; the last frame's paragraphs are indented 10 pt. → the session and the frames. +fn split_story() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3); + let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::>().join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("text.thread.add", &json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [c], "leftIndent": 10})).unwrap(); + for id in [b, c] { + assert!(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap().first_para_continues, "frame {id} starts inside a paragraph"); + } + (s, [a, b, c]) +} + +/// Each frame's text, paragraph styles and whether it continues a paragraph. +fn frames_of(s: &Session, frames: &[u64]) -> Vec<(String, Vec, bool)> { + frames + .iter() + .map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + (t.plain_text(), t.paragraph_styles(), t.first_para_continues) + }) + .collect() +} + +/// One entry for each paragraph of the story: a paragraph split between frames by its piece in the +/// later frame. Each takes indents of its own, wide enough to move text between the frames. +fn indent_entries(s: &Session, frames: &[u64]) -> Vec { + let mut out = vec![]; + for (k, id) in frames.iter().enumerate() { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + let next_continues = frames.get(k + 1).is_some_and(|n| text_of(&s.doc().unwrap().doc, NodeId(*n)).unwrap().first_para_continues); + let plain = t.plain_text(); + let pieces: Vec<&str> = plain.split('\n').collect(); + let mut start = 0; + for (i, piece) in pieces.iter().enumerate() { + if i + 1 < pieces.len() || !next_continues { + let n = out.len() as f64; + out.push(json!({"id": id, "start": start, "leftIndent": 20.0 + n, "firstLineIndent": 15.0 - n})); + } + start += piece.len() + 1; + } + } + out +} + +/// `paragraphIndents` sets each paragraph of a threaded story as one `text.setFormat` command for +/// each paragraph would, the story's last paragraph first, with a paragraph split between frames +/// set once; the story flows again once, and Undo and Redo take the whole change as one step. +#[test] +fn paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would() { + let (mut one, frames) = split_story(); + let (mut each, _) = split_story(); + let entries = indent_entries(&one, &frames); + assert_eq!(entries.len(), 12, "one entry for each paragraph of the story: {entries:?}"); + let commands: Vec = entries + .iter() + .rev() + .map(|e| { + json!({"command": "text.setFormat", "params": { + "ids": [e["id"]], "start": e["start"], "end": e["start"], "leftIndent": e["leftIndent"], "firstLineIndent": e["firstLineIndent"] + }}) + }) + .collect(); + each.execute("command.batch", &json!({"label": "Left Indent", "commands": commands})).unwrap(); + let start = frames_of(&one, &frames); + let undo = one.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = one.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!(COPIED.with(std::cell::Cell::get), copied + 1, "the story flows again once"); + // The first frame holds only the start of the paragraph that runs on into the second. + assert_eq!(r, json!({"ids": frames[1..]})); + assert_eq!(one.doc().unwrap().history.undo.len(), undo + 1); + let set = frames_of(&one, &frames); + assert_ne!(set, start); + assert_eq!(set, frames_of(&each, &frames)); + one.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), start); + one.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), set); +} + +/// Thousands of paragraphs of a threaded story are set by one edit, which flows the story again +/// once. +#[test] +fn paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let story = vec!["x"; 5000].join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 300}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 300]})).unwrap()["id"].as_u64().unwrap(); + let entries: Vec = [a, b] + .into_iter() + .flat_map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap(); + let count = t.paragraph_count(); + (0..count).map(move |i| json!({"id": id, "start": 2 * i, "leftIndent": (i % 7) as f64})) + }) + .collect(); + assert!(entries.len() >= 5000, "{}", entries.len()); + let (undo, copied) = (s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)); + s.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo + 1, copied + 1)); + let t = text_of(&s.doc().unwrap().doc, NodeId(a)).unwrap(); + assert_eq!((t.para_at(0).left_indent, t.para_at(1).left_indent, t.para_at(8).left_indent), (0.0, 1.0, 1.0)); +} + +/// `paragraphIndents` that would change no paragraph leaves the document and its history alone, and +/// a bad one is refused before anything changes. +#[test] +fn paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing() { + let (mut s, [a, b, c]) = split_story(); + let start = frames_of(&s, &[a, b, c]); + let undo = s.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = + s.execute("text.setFormat", &json!({"paragraphIndents": [{"id": a, "start": 0, "leftIndent": 0}, {"id": c, "start": 0, "leftIndent": 10}]})); + assert_eq!(r.unwrap(), json!({"ids": [a, c]})); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo, copied), "nothing to change"); + let paragraph = text(&s, c).find('\n').unwrap() + 1; + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 900, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let ok = json!({"id": c, "start": paragraph, "leftIndent": 30}); + for bad in [ + json!({"paragraphIndents": []}), + json!({"paragraphIndents": {"id": a, "start": 0, "leftIndent": 30}}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "ids": [a]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "leftIndent": 5}), + json!({"paragraphIndents": [ok, 3]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": "30"}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 1300}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "firstLineIndent": -1297}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30, "spaceBefore": 4}]}), + json!({"paragraphIndents": [ok, {"id": "a", "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": rect, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": 999_999, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": -1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 31}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 30, "firstLineIndent": 0}]}), + json!({"paragraphIndents": vec![ok.clone(); crate::cmd::panelcmds::MAX_PARAGRAPH_INDENTS + 1]}), + ] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(frames_of(&s, &[a, b, c]), start, "{bad}"); + } + // The same entry twice is one change. + s.execute("text.setFormat", &json!({"paragraphIndents": [ok, ok]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(text_of(&s.doc().unwrap().doc, NodeId(c)).unwrap().para_at(1).left_indent, 30.0); +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 0328667d82..e996591f91 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), @@ -715,6 +715,20 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result Result { + let (lo, hi) = TYPE_SIZES; + if !(lo..=hi).contains(&size) { + let key = "size"; + return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))); + } + Ok(size) +} + /// Clamp in the units shown to the user, then compensate the text's affine. Clamping /// the local result would make small sizes impossible after enlarging an object. pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result { @@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setStyle"; let font = text_param(p, "font", C)?.map(str::to_string); let style = text_param(p, "style", C)?.map(str::to_string); - let size = length_given(p, "size", C)?; - if size.is_some_and(|v| v <= 0.0) { - return Err(bad(C, "size must be positive")); - } + let size = length_given(p, "size", C)?.map(|v| given_type_size(v, C)).transpose()?; let leading = match p.get("leading") { None | Some(Value::Null) => None, Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 5545e1be94..89fff3843c 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -440,6 +440,59 @@ fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); } +/// Paragraph panel menu › Bunri-Kinshi: off for new type and for a paragraph saved before it, and +/// saved only when on; `text.setFormat {bunriKinshi}` sets it per paragraph in one undo step and +/// refuses a value that is not true or false, kinsoku None keeps it, and paragraph styles carry it. +#[test] +fn bunri_kinshi_is_set_per_paragraph_and_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "あ——い\nう……え"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).bunri_kinshi, "new type has it off"); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("bunri_kinshi").is_none()); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(!old.bunri_kinshi, "saved before the option: off"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the second paragraph. + let second = "あ——い\n".len(); + s.execute("text.setFormat", &json!({"bunriKinshi": true, "start": second, "end": second})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let saved = serde_json::to_value(text(&s).para_at(1)).unwrap(); + assert_eq!(saved["bunri_kinshi"], true); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(saved).unwrap(); + assert!(back.bunri_kinshi); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert!(!back.para_at(0).bunri_kinshi && back.para_at(1).bunri_kinshi, "the file keeps it per paragraph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(1).bunri_kinshi); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + for bad in [json!("yes"), json!(1)] { + assert!(s.execute("text.setFormat", &json!({"ids": [id], "bunriKinshi": bad, "kinsoku": "none"})).is_err(), "{bad}"); + } + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "bunriKinshi": false})).is_err()); + assert!(text(&s).para_at(1).bunri_kinshi && text(&s).para_at(1).kinsoku != vectorcraft_doc::Kinsoku::None, "a refused call changes nothing"); + // Kinsoku None keeps the setting, without effect, and turning kinsoku back on restores it. + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "none"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "hard"})).unwrap(); + assert!(text(&s).para_at(1).bunri_kinshi); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Pairs", "attrs": {"bunri_kinshi": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Pairs", "id": id})).unwrap(); + assert!(text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi); + s.execute("paraStyle.new", &json!({"name": "Split", "attrs": {"bunri_kinshi": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Split", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).bunri_kinshi && !text(&s).para_at(1).bunri_kinshi); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. @@ -2156,3 +2209,127 @@ fn set_path_handle_reshapes_type_on_a_path() { assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor"); assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type"); } + +/// `text.setFormat` refuses a number or switch given as another type, naming the key, and +/// changes nothing; null leaves the attribute as it is. +#[test] +fn set_format_refuses_a_param_of_the_wrong_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for key in [ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", + ] { + for wrong in [json!("30"), json!(true), json!([30])] { + let err = s.execute("text.setFormat", &json!({ key: wrong })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be a number, not {wrong}")), "{key}: {err}"); + } + } + for key in ["underline", "allCaps", "hyphenate", "noBreak"] { + let err = s.execute("text.setFormat", &json!({ key: "true" })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be true or false")), "{key}: {err}"); + } + let err = s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": "12"})).unwrap_err().to_string(); + assert!(err.contains("`firstLineIndent` must be a number"), "{err}"); + assert!(state(&s) == before, "a refused call changes nothing and records no undo step"); + s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": null})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (30.0, 0.0)); +} + +/// `text.setFormat` reads a null value as one left out: a call whose attributes are all null has +/// nothing to change, and records no edit, no undo step and no new revision. +#[test] +fn set_format_with_only_null_values_changes_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + for params in [ + json!({"leftIndent": null}), + json!({"leftIndent": null, "firstLineIndent": null, "underline": null}), + json!({"kerning": null, "antiAlias": null, "list": null, "wordSpacing": null}), + json!({"mojikumi": null}), + json!({"kinsoku": null, "kinsokuType": null, "direction": null, "leadingModel": null}), + json!({"burasagari": null, "kashida": null, "singleWordJustification": null, "autoLeading": null}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": null}), + ] { + let err = s.execute("text.setFormat", ¶ms).unwrap_err().to_string(); + assert!(err.contains("nothing to change"), "{params}: {err}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A null beside a value leaves only its own attribute. + let para = |d: &vectorcraft_doc::Document| { + let NodeKind::Text(t) = &d.node(NodeId(id)).unwrap().kind else { panic!("text") }; + (t.para_at(0).mojikumi, t.para_at(0).kinsoku) + }; + let mojikumi = para(&before.0).0; + s.execute("text.setFormat", &json!({"mojikumi": null, "kinsoku": "soft"})).unwrap(); + assert_eq!(para(&s.doc().unwrap().doc), (mojikumi, vectorcraft_doc::Kinsoku::Soft)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 1, "one undo step"); +} + +/// `text.setFormat` with the values the text already has succeeds and names its objects, but +/// records no edit, no undo step and no new revision. +#[test] +fn set_format_with_the_current_values_records_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + let NodeKind::Text(t) = &before.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let hyphenate = t.para_at(0).hyphenate; + for params in [ + json!({"leftIndent": 0, "firstLineIndent": 0}), + json!({"underline": false, "strikethrough": false, "hyphenate": hyphenate}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": 0, "spaceAfter": 0}), + json!({"ids": [id], "start": 4, "end": 7, "leftIndent": null, "rightIndent": 0}), + ] { + let out = s.execute("text.setFormat", ¶ms).unwrap(); + assert_eq!(out["ids"], json!([id]), "{params}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A call that changes a value is one undo step; the same call again records nothing. + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + let changed = state(&s); + assert_eq!((changed.1, changed.2), (before.1 + 1, before.2 + 1), "one undo step and one revision"); + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + assert!(state(&s) == changed, "the same value again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc == before.0, "one undo restores the text"); + // The anti-aliasing method is compared the same way. + s.execute("text.setFormat", &json!({"antiAlias": "none"})).unwrap(); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + let strong = state(&s); + assert!(strong.1 > before.1, "a new method is an undo step"); + let NodeKind::Text(t) = &strong.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.anti_alias, vectorcraft_doc::text::TypeAntiAlias::Strong); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + assert!(state(&s) == strong, "the same method again records nothing"); +} diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..74c89d1089 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -27,6 +27,7 @@ mod csv; mod library; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; +pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; /// The longest variable or dataset name, in characters (names arrive from MCP and files). @@ -46,9 +47,9 @@ fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> { variables_unlocked(s) } -/// Ends a typing session as [`Session::edit`] would, before a helper below compares the document, +/// Ends a typing session as [`Session::edit`] would, before a command compares the document, /// so the typing is its own undo step whether or not the command then changes anything. -fn settle_typing(s: &mut Session) -> Result<()> { +pub(super) fn settle_typing(s: &mut Session) -> Result<()> { if !s.previewing && super::typing_in_progress(s) { super::settle_interaction(s)?; } diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 54f3689e99..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; + use super::{ - BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named, - read_file_head, str_param, variables_unlocked, write_file, + BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind, + Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file, }; /// The namespace of a variable library's elements, the default namespace of its data sets' values, @@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result { } /// A variable library as Load Variable Library reads it: the names of its text variables in the -/// order it declares them, the name and `trait` of each variable of another kind, and its data -/// sets with their text values. -struct Library { +/// order it declares them, the name and `trait` of each variable of another kind, its data sets +/// with their text values, and the element id each text variable's `docRef` names. +pub(crate) struct Library { text: Vec, other: Vec<(String, String)>, datasets: Vec, + refs: Vec<(String, String)>, +} + +impl Library { + /// The element ids the text variables' `docRef`s name. + pub(crate) fn elements(&self) -> std::collections::HashSet { + self.refs.iter().map(|(_, e)| e.clone()).collect() + } } /// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each @@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, "the file holds no `variableSets` element")); }; let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); - let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { @@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } match var.attribute("trait") { - Some("textcontent") => text.push(name), + Some("textcontent") => { + // `docRef="id('…')"` names the element of the variable's bound object. + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + if let Some(element) = element { + refs.push((name.clone(), element.to_string())); + } + text.push(name); + } Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), } @@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result { } datasets.push(DataSet { name, values }); } - Ok(Library { text, other, datasets }) + Ok(Library { text, other, datasets, refs }) +} + +/// The variable library an SVG carries (SVG Options › Include Variable Data), read as +/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The +/// element is read on its own, from its start tag to the end tag that closes it +/// ([`variable_sets_range`]), with each reference to an +/// entity whose name starts with `ns_` replaced by the value the text before the element declares +/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as +/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and +/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An +/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused. +pub(crate) fn svg_library(svg: &str) -> Option> { + const C: &str = "document.open"; + let (start, end) = variable_sets_range(svg)?; + let read = || { + let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?; + let element = svg.get(start..end).unwrap_or_default(); + if element.len() > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut entities: Vec<(String, String)> = Vec::new(); + for decl in svg.get(..start).unwrap_or_default().split("(); + if element.len() + grown > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut element = element.to_string(); + for (name, value) in &entities { + element = element.replace(name.as_str(), value); + } + parse_library(&format!("{element}"), C) + }; + Some(read()) +} + +/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag +/// that closes it ends (`None` when no end tag does), or `None` when there is no such element. +/// Comments, CDATA sections, processing instructions and the document type declaration are +/// skipped, so text inside them that looks like the element is not taken for it. +fn variable_sets_range(svg: &str) -> Option<(usize, Option)> { + const NAME: &str = "variableSets"; + // The element's name ends at white space, `>` or `/`. + let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/'))); + if !svg.contains("") + } else if rest.starts_with("") + } else if rest.starts_with("") + } else if rest.starts_with("`. + match (rest.find('['), rest.find('>')) { + (Some(open), Some(gt)) if open < gt => { + rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1)) + } + (_, gt) => gt.map(|g| at + g + 1), + } + } else if let Some(close) = rest.strip_prefix(""); + if let Some(s) = start + && named(close) + { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some((s, tag_end)); + } + } + tag_end + } else { + let tag_end = past(">"); + if named(rest.get(1..).unwrap_or_default()) { + let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>")); + if start.is_none() { + start = Some(at); + } + if empty && depth == 0 { + return Some((at, tag_end)); + } + depth += usize::from(!empty); + } + tag_end + }; + let Some(next) = next else { break }; + at = next; + } + start.map(|s| (s, None)) +} + +/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the +/// library's text variables, its data sets, no active data set, and each text variable bound to +/// the type object made of the element its `docRef` names, or the one type object among the +/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef` +/// that names no type object, or one another variable is bound to already, binds nothing. The art +/// stays as the file draws it. The warning about the variables of other kinds that were left out, +/// if any. +pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap) -> Option { + let mut vars = + Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() }; + vars.datasets = lib.datasets; + // A type object written with a paint of its own, or with fills and strokes of its appearance, + // is a group around its text (and the appearance's paths). + let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => Some(id), + Some(NodeKind::Group { children, .. }) => { + let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_))); + match (texts.next(), texts.next()) { + (Some(one), None) => Some(one.id), + _ => None, + } + } + _ => None, + }; + for (name, element) in &lib.refs { + let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue }; + if !vars.bindings.contains_key(&id) { + vars.bind(id, name); + } + } + doc.variables = vars.into(); + left_out_warning(lib.other.iter().map(|(_, t)| t.as_str())) } /// Load Variable Library: replace the document's variables and data sets with a variable library's diff --git a/crates/engine/src/cmd/wireframe.rs b/crates/engine/src/cmd/wireframe.rs new file mode 100644 index 0000000000..eeee004b07 --- /dev/null +++ b/crates/engine/src/cmd/wireframe.rs @@ -0,0 +1,148 @@ +//! Window › 3D and Materials › Quick Actions › Expand as Wireframes: each object with a 3D effect +//! becomes a group of stroked paths, the edges of the faces its effect draws. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::appearance::AppearanceItem; +use vectorcraft_doc::{Appearance, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_render::effects; + +use super::{CommandSpec, Result, Session, bad, checked_ids_or_selection, cmd, has_doc}; + +const C: &str = "effect.expandAsWireframes"; + +/// The name of the undo step. +const LABEL: &str = "Expand as Wireframes"; + +/// The weight of a wireframe edge's stroke, in points (VectorCraft's own). +pub const WIRE_WEIGHT: f64 = 0.5; + +pub fn specs() -> Vec { + vec![cmd!( + "effect.expandAsWireframes", + "Expand as Wireframes", + [], + None, + "{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected, and its ruler guides and paths made into guides; a path inside a compound path in ids stands for the compound path); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}", + has_doc, + expand_as_wireframes + )] +} + +/// A wireframe edge's appearance: a 0.5 pt black stroke with round joins and no fill. +fn wire_appearance() -> Appearance { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT); + stroke.join = LineJoin::Round; + Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() } +} + +/// The guides (ruler guides and paths made into guides) of layer `n` and then those of the groups +/// and sublayers inside it, in order, added to `out`. A guide from a hidden or locked sublayer, or +/// from one inside a hidden or locked group or sublayer, is hidden or locked itself. +fn guides(n: &Node, hidden: bool, locked: bool, out: &mut Vec>) { + let Some(children) = n.children() else { return }; + for c in children.iter().filter(|c| c.is_guide()) { + let mut g = c.clone(); + if hidden || locked { + let g = Arc::make_mut(&mut g); + g.visible &= !hidden; + g.locked |= locked; + } + out.push(g); + } + for c in children.iter().filter(|c| !c.is_guide()) { + guides(c, hidden || !c.visible, locked || c.locked, out); + } +} + +fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result { + let roots = checked_ids_or_selection(s, p, C)?; + if roots.is_empty() { + return Err(bad(C, "select an object with a 3D effect, or give its id in ids")); + } + // Every wireframe is built before the edit, so a failure leaves the document and its history as + // they are. + let d = &s.doc()?.doc; + let mut wires = vec![]; + let mut seen = std::collections::HashSet::new(); + for id in roots { + // A path inside a compound path stands for the compound path, which draws it. An id given + // twice is expanded once. + let id = super::path::compound_or_self(d, id); + if !seen.insert(id) { + continue; + } + let Some(n) = d.node(id) else { continue }; + if !effects::has_revolve(n) && !effects::has_solid(n) { + continue; + } + let wire = effects::three_d_wireframe_in(Some(d), n).map_err(|e| bad(C, format!("object {}: {e}", id.0)))?; + wires.push((id, wire)); + } + if wires.is_empty() { + return Err(bad(C, "no selected or given object has a visible 3D effect")); + } + let ids: Vec = s.edit(LABEL, |d, sel| { + let mut out = vec![]; + let mut selected = vec![]; + for (id, wire) in wires { + let Some(source) = d.node(id) else { continue }; + if matches!(source.kind, NodeKind::Layer { .. }) { + // A layer stays a layer: its members become the edges, and its 3D effect is removed. + // Its guides and those of its sublayers are not art: they stay, after the edges. + let mut layer = source.clone(); + let mut kept = vec![]; + guides(source, false, false, &mut kept); + let mut children: Vec> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + selected.extend(children.iter().map(|c| c.id)); + children.extend(kept); + if let Some(members) = layer.children_mut() { + *members = children; + } + layer.appearance = Default::default(); + if let Some(slot) = d.node_mut(id) { + *slot = layer; + out.push(id); + } + continue; + } + // The group keeps the object's name, visibility, lock, bounding box angle, transparency + // (opacity, blending mode, isolation, knockout and opacity mask), attributes, slice and text + // wrap. + let mut group = Node::group(id, vec![]); + group.name = source.name.clone(); + group.visible = source.visible; + group.locked = source.locked; + group.opacity = source.opacity; + group.blend = source.blend; + group.isolate = source.isolate; + group.knockout = source.knockout; + group.knockout_shape = source.knockout_shape; + group.bbox_angle = source.bbox_angle; + let extras = source.extras(); + let (mask, attrs, slice, wrap) = (extras.mask.clone(), extras.attrs.clone(), extras.slice.clone(), extras.wrap); + if mask.is_some() || attrs.is_some() || slice.is_some() || wrap.is_some() { + let mut e = group.extras_mut(); + e.mask = mask; + e.attrs = attrs; + e.slice = slice; + e.wrap = wrap; + } + let children = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect(); + group.kind = NodeKind::Group { children, clip: false }; + if let Some(slot) = d.node_mut(id) { + *slot = group; + out.push(id); + selected.push(id); + } + } + sel.set(selected); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|id| id.0).collect::>() })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/wireframe/tests.rs b/crates/engine/src/cmd/wireframe/tests.rs new file mode 100644 index 0000000000..7d9265bdbc --- /dev/null +++ b/crates/engine/src/cmd/wireframe/tests.rs @@ -0,0 +1,240 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::appearance::AppearanceItem; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A new document with a filled 100 pt square; `effect` is applied to it when given. +fn setup(effect: Option<&str>) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + if let Some(effect) = effect { + s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":{}})).unwrap(); + } + (s, id) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn run(s: &mut Session, p: Value) -> crate::Result { + s.execute("effect.expandAsWireframes", &p) +} + +#[test] +fn a_3d_object_becomes_a_selected_group_of_stroked_edges_in_one_undo_step() { + for effect in [ + "threeD.extrude", + "threeD.classicExtrude", + "threeD.revolve", + "threeD.classicRevolve", + "threeD.inflate", + "threeD.rotate", + "threeD.classicRotate", + ] { + let (mut s, id) = setup(Some(effect)); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().name = Some("Box".into()); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let out = run(&mut s, json!({})).unwrap_or_else(|e| panic!("{effect}: {e}")); + assert_eq!(out["ids"], json!([id.0]), "{effect}"); + let st = s.doc().unwrap(); + let group = st.doc.node(id).unwrap(); + let NodeKind::Group { children, clip: false } = &group.kind else { panic!("{effect}: a group") }; + assert!(!children.is_empty(), "{effect}"); + assert_eq!(group.name.as_deref(), Some("Box"), "{effect}"); + assert!(group.appearance.effects.is_empty(), "{effect}: the effect is gone"); + for c in children { + assert!(c.path_data().is_some(), "{effect}: paths only"); + let [AppearanceItem::Stroke(stroke)] = c.appearance.items.as_slice() else { panic!("{effect}: one stroke and no fill") }; + assert_eq!(stroke.width, super::WIRE_WEIGHT); + } + assert_eq!(st.selection.objects, vec![id], "{effect}"); + assert_eq!(undo_len(&s), history + 1, "{effect}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "{effect}"); + } +} + +#[test] +fn the_group_keeps_the_object_s_transparency() { + let (mut s, id) = setup(Some("threeD.extrude")); + s.execute("transparency.set", &json!({"opacity": 50, "knockout": "on"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().bbox_angle = 0.25; + let before = s.doc().unwrap().doc.node(id).unwrap().clone(); + run(&mut s, json!({})).unwrap(); + let group = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!((group.opacity - 0.5).abs() < 1e-6, "{}", group.opacity); + assert_eq!((group.opacity, group.blend, group.knockout), (before.opacity, before.blend, before.knockout)); + assert_eq!(group.bbox_angle, 0.25, "the bounding box keeps its angle"); +} + +#[test] +fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() { + let (mut s, _) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let history = undo_len(&s); + // An id given twice is expanded once. + let out = run(&mut s, json!({"ids":[layer.0, layer.0]})).unwrap(); + assert_eq!(out["ids"], json!([layer.0])); + let st = s.doc().unwrap(); + let node = st.doc.node(layer).unwrap(); + assert!(matches!(node.kind, NodeKind::Layer { .. })); + assert!(node.appearance.effects.is_empty()); + let children = node.children().unwrap(); + assert!(!children.is_empty() && children.iter().all(|c| c.path_data().is_some())); + assert_eq!(st.selection.objects, children.iter().map(|c| c.id).collect::>()); + assert_eq!(undo_len(&s), history + 1); +} + +/// A layer's ruler guides are not art: they stay on the layer, after the edges, and are not selected. +#[test] +fn a_layer_keeps_its_ruler_guides() { + let (mut s, _) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap(); + let guides = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_ruler_guide()).map(|c| c.id).collect() + }; + let before = guides(&s); + assert_eq!(before.len(), 1); + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(guides(&s), before); + let st = s.doc().unwrap(); + let children = st.doc.node(layer).unwrap().children().unwrap(); + assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_ruler_guide())); + assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|id| !before.contains(id))); +} + +/// A path made into a guide is not art either: it stays on the layer, after the edges, and is not +/// selected. +#[test] +fn a_layer_keeps_its_path_guides() { + let (mut s, id) = setup(None); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("path.create", &json!({"d":"M0 300L300 300"})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + let guides = |s: &Session| -> Vec { + s.doc().unwrap().doc.node(layer).unwrap().children().unwrap().iter().filter(|c| c.is_guide()).map(|c| c.id).collect() + }; + let before = guides(&s); + assert_eq!(before.len(), 1); + assert_ne!(before[0], id); + s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + run(&mut s, json!({"ids":[layer.0]})).unwrap(); + assert_eq!(guides(&s), before); + let st = s.doc().unwrap(); + let children = st.doc.node(layer).unwrap().children().unwrap(); + assert!(children.len() > 1 && children.last().is_some_and(|c| c.is_guide())); + assert!(!st.selection.objects.is_empty() && st.selection.objects.iter().all(|i| !before.contains(i))); +} + +/// The number of segments in the wireframe group `id`. +fn segments(s: &Session, id: NodeId) -> usize { + let st = s.doc().unwrap(); + st.doc.node(id).unwrap().children().unwrap().iter().flat_map(|c| &c.path_data().unwrap().subpaths).map(|sp| sp.segment_count()).sum() +} + +#[test] +fn a_box_expands_to_the_nine_edges_of_its_three_faces() { + let (mut s, id) = setup(Some("threeD.extrude")); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + run(&mut s, json!({"ids":[id.0]})).unwrap(); + assert_eq!(segments(&s, id), 9); + // A stroke is outlined before the effect, so its own solid adds its edges. + let (mut s, id) = setup(Some("threeD.extrude")); + run(&mut s, json!({"ids":[id.0]})).unwrap(); + assert!(segments(&s, id) > 9); +} + +#[test] +fn objects_without_a_3d_effect_are_left_and_none_at_all_is_an_error() { + let (mut s, plain) = setup(None); + let history = undo_len(&s); + let before = s.doc().unwrap().doc.clone(); + let e = run(&mut s, json!({})).unwrap_err().to_string(); + assert!(e.contains("no selected or given object has a visible 3D effect"), "{e}"); + assert_eq!(undo_len(&s), history, "no undo step when nothing changes"); + assert_eq!(s.doc().unwrap().doc, before); + + // A hidden 3D effect draws nothing to expand. + s.execute("effect.apply", &json!({"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"visible":false})).unwrap(); + assert!(run(&mut s, json!({})).is_err()); + + // With nothing selected. + s.execute("select.none", &json!({})).unwrap(); + let e = run(&mut s, json!({})).unwrap_err().to_string(); + assert!(e.contains("select an object with a 3D effect"), "{e}"); + + // A second object with a 3D effect expands; the plain one is left as it is. + let other = NodeId(s.execute("path.create", &json!({"d":"M250 250L300 250L300 300Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.remove", &json!({"ids":[plain.0],"index":0,"item":null})).unwrap(); + s.execute("paint.setFill", &json!({"ids":[other.0],"color":"#ff0000"})).unwrap(); + s.execute("effect.apply", &json!({"ids":[other.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let kept = s.doc().unwrap().doc.node(plain).unwrap().clone(); + let out = run(&mut s, json!({"ids":[plain.0, other.0]})).unwrap(); + assert_eq!(out["ids"], json!([other.0])); + assert_eq!(s.doc().unwrap().doc.node(plain).unwrap(), &kept); +} + +/// A live-effect plug-in after a fill's 3D effect that links its pieces to one of the document's +/// swatches reshapes the edges, as it reshapes the drawn surface. +#[test] +fn a_painted_effect_after_a_fill_s_3d_effect_finds_the_document_s_swatches() { + use vectorcraft_geom::{PathData, Point, SubPath}; + let mut leaf = serde_json::to_value(vectorcraft_color::Paint::solid(vectorcraft_color::Color::from_hex("#00ff00").unwrap())).unwrap(); + leaf["swatch"] = json!("Leaf"); + let corners = [Point::new(300.0, 300.0), Point::new(320.0, 300.0), Point::new(320.0, 320.0), Point::new(300.0, 320.0)]; + let output = json!({"objects": [{"path": PathData::single(SubPath::polyline(&corners, true)), "fills": [leaf]}]}).to_string(); + let manifest = r#"{"id":"test.engine.wireleaf","name":"Test wireleaf","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#; + let wasm = wat::parse_str(vectorcraft_plugins::wat::returning(manifest, &output)).unwrap(); + for effect in ["threeD.extrude", "threeD.revolve"] { + let (mut s, id) = setup(None); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "replace": true})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Leaf", "color": "#00ff00", "global": true})).unwrap(); + let items = &s.doc().unwrap().doc.node(id).unwrap().appearance.items; + let fill = items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + for e in [effect, "plugin.test.engine.wireleaf"] { + s.execute("effect.apply", &json!({"ids": [id.0], "effect": e, "item": fill, "params": {}})).unwrap(); + } + run(&mut s, json!({"ids": [id.0]})).unwrap_or_else(|e| panic!("{effect}: {e}")); + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (300.0, 300.0, 320.0, 320.0), "{effect}: the plug-in's square"); + assert_eq!(segments(&s, id), 4, "{effect}"); + } +} + +/// A path inside a compound path in `ids` stands for the compound path: the compound path becomes +/// the group, and no member is replaced by a group inside it. +#[test] +fn a_compound_path_member_in_ids_expands_the_compound_path() { + let (mut s, a) = setup(None); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids":[a.0, b.0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"ids":[c.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap(); + let member = s.doc().unwrap().doc.node(c).unwrap().children().unwrap()[0].id; + let out = run(&mut s, json!({"ids":[member.0]})).unwrap(); + assert_eq!(out["ids"], json!([c.0])); + let st = s.doc().unwrap(); + let NodeKind::Group { children, clip: false } = &st.doc.node(c).unwrap().kind else { panic!("the compound path is a group") }; + assert!(!children.is_empty() && children.iter().all(|ch| ch.path_data().is_some())); +} + +#[test] +fn bad_ids_are_errors() { + let (mut s, _) = setup(Some("threeD.extrude")); + for p in [json!({"ids":[9999]}), json!({"ids":"x"}), json!({"ids":[-1]})] { + let history = undo_len(&s); + assert!(run(&mut s, p.clone()).is_err(), "{p}"); + assert_eq!(undo_len(&s), history, "{p}"); + } +} diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 9038598659..617d9d944c 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Free Distort", [], None, - "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles", + "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", has_doc, distort ), @@ -104,7 +104,8 @@ impl Projective { } /// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine -/// approximation at their centre. +/// approximation at their center. The art of a linked opacity mask is warped with the object it +/// masks; an unlinked one stays where it is. fn warp_node(n: &mut Node, pr: &Projective) { n.pin_gradients(); match &mut n.kind { @@ -131,6 +132,9 @@ fn warp_node(n: &mut Node, pr: &Projective) { return; } } + if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) { + warp_node(Arc::make_mut(&mut m.art), pr); + } n.appearance.warp_gradients(&|p| pr.affine_near(p)); } diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 95b44776f0..c9de286514 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -84,7 +84,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) { /// Queries that apply the edit the active tool is computing before they run, as edits do /// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All /// Instances and Lock Guides change the selection, and the proof views, the preferences and added -/// fonts give the documents new revisions (they draw again, and type lays out again). +/// fonts can give the documents new revisions (they draw again, and type lays out again). const WAIT_FOR_TOOL_EDIT: &[&str] = &[ "script.run", "command.batch", diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 08b8a2f294..0ef7555e02 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -34,6 +34,9 @@ pub struct DocState { /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. pub global_edit: Option, + /// The objects Start Global Edit would take, highlighted before it starts + /// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable. + pub(crate) similar_outlines: cmd::globaledit::SimilarCache, /// What leaving symbol editing mode goes back to ([`cmd::symboledit`]). pub(crate) symbol_edit_return: cmd::symboledit::Return, pub interaction: Option, @@ -302,6 +305,7 @@ impl DocState { variables_highlight: None, isolation: None, global_edit: None, + similar_outlines: Default::default(), symbol_edit_return: Default::default(), interaction: None, undo_group: None, diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 73c8ba8efe..6ca724d810 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,20 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder. +pub fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && current().is_some() +} + +/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, +/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, +/// `destinationFolder`)? +pub fn is_path_key(key: &str) -> bool { + matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) +} + /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Write))?; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 58832adf29..146583778f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value { "title": st.title(), // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name. "fileInfoTitle": d.info_title(), - "path": st.path, + // Null for a confined caller when the file is outside its read roots. + "path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()), "dirty": st.is_dirty(), "revision": st.revision, "units": d.units.label(), diff --git a/crates/engine/src/interaction.rs b/crates/engine/src/interaction.rs index 1de58228a2..e3cd563d55 100644 --- a/crates/engine/src/interaction.rs +++ b/crates/engine/src/interaction.rs @@ -27,6 +27,9 @@ pub struct Interaction { /// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here /// ([`PreviewCopies`]). pub(crate) copies: Option>, + /// The bounding box of `selection` in `doc`, measured once for a move drag + /// ([`crate::Session::move_drag_box`]): each step offsets it by the drag's move. + pub(crate) move_box: std::sync::OnceLock>, } /// What Object → Transform → Transform Again (⌘D) repeats ([`SessionState::last_transform`]). diff --git a/crates/engine/src/journal.rs b/crates/engine/src/journal.rs index 230bcfba0d..c9a2547b3c 100644 --- a/crates/engine/src/journal.rs +++ b/crates/engine/src/journal.rs @@ -25,6 +25,8 @@ pub struct Journal { bytes: usize, /// Keep every entry from this number on ([`Journal::hold`]). held: Option, + /// How many times kept entries were changed, dropped or renumbered ([`Journal::rewrites`]). + rewrites: u64, } /// Journals with the same entries (by number) are equal, whatever their history. @@ -66,14 +68,24 @@ impl Journal { self.entries.get(i.checked_sub(self.dropped)?) } pub fn get_mut(&mut self, i: usize) -> Option<&mut JournalEntry> { + self.rewrites += 1; self.entries.get_mut(i.checked_sub(self.dropped)?) } pub fn last(&self) -> Option<&JournalEntry> { self.entries.last() } pub fn last_mut(&mut self) -> Option<&mut JournalEntry> { + self.rewrites += 1; self.entries.last_mut() } + /// A number that grows whenever a kept entry may have changed, gone or been renumbered + /// ([`Journal::truncate`], [`Journal::remove`], [`Journal::replace`], [`Journal::get_mut`], + /// [`Journal::last_mut`]), and that [`Journal::push`] and letting old entries go leave as it + /// is. While it stays the same, the entries from a held number on are the ones counted before, + /// with the new ones after them. + pub fn rewrites(&self) -> u64 { + self.rewrites + } pub fn push(&mut self, e: JournalEntry) { let size = entry_bytes(&e); self.entries.push(e); @@ -83,6 +95,7 @@ impl Journal { } /// Drop the entries from number `len` on. pub fn truncate(&mut self, len: usize) { + self.rewrites += 1; let keep = len.saturating_sub(self.dropped); self.entries.truncate(keep); self.sizes.truncate(keep); @@ -92,6 +105,7 @@ impl Journal { /// Drop the entries numbered `numbers` (sorted, a number may repeat; the entries of canceled undo /// groups). The entries after them are numbered down to close the gaps. pub fn remove(&mut self, numbers: &[usize]) { + self.rewrites += 1; let dropped = self.dropped; let gone = |i: usize| numbers.binary_search(&(dropped + i)).is_ok(); let mut i = 0; @@ -115,6 +129,7 @@ impl Journal { } /// Put `e` in place of entry number `i`, if kept. pub fn replace(&mut self, i: usize, e: JournalEntry) { + self.rewrites += 1; let Some(k) = i.checked_sub(self.dropped) else { return }; if let (Some(old), Some(size)) = (self.entries.get_mut(k), self.sizes.get_mut(k)) { *size = entry_bytes(&e); @@ -253,4 +268,29 @@ mod tests { j.replace(29_000, big(0)); assert_eq!(j.bytes, j.iter().map(entry_bytes).sum::()); } + + #[test] + fn rewrites_count_changes_to_kept_entries_but_not_new_ones() { + let mut j = Journal::default(); + j.hold(Some(0)); + for i in 0..100 { + j.push(("object.move".into(), json!({"dx": i}))); + } + assert_eq!(j.rewrites(), 0, "pushes add entries after the counted ones"); + let changes: [fn(&mut Journal); 5] = [ + |j| j.truncate(90), + |j| j.remove(&[3]), + |j| j.replace(4, ("object.group".into(), json!({}))), + |j| { + let _ = j.get_mut(5); + }, + |j| { + let _ = j.last_mut(); + }, + ]; + for (i, change) in changes.into_iter().enumerate() { + change(&mut j); + assert_eq!(j.rewrites(), i as u64 + 1); + } + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9890c9b08c..b211c3d727 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,11 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one. + pub arrowheads: cmd::arrowheads::Arrowheads, + /// The id each loaded library outside a request's read roots has been shown under, by its own id + /// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session. + shown_library_ids: std::sync::Mutex>, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index aa8b5c7f42..52c4b7a4f2 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -71,6 +71,11 @@ pub struct Prefs { /// A single-key tool shortcut held down chooses its tool until the key is let go, and then the /// tool active before comes back; a quick press chooses the tool for good. pub hold_tool_keys: bool, + /// With View › Show Bounding Box on, the bounding box stays visible while a drag moves the + /// selection, and the shape tools that show it draw it around the shape being drawn. Off (the + /// default), it hides during a move and while a shape is drawn, except that a Selection tool + /// move with Real-time Drawing off still moves the box. + pub bounding_box_while_moving_and_drawing: bool, /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). pub paste_offset: f64, // Selection & Anchor Display @@ -397,6 +402,7 @@ impl Default for Prefs { scrub_numeric_fields: true, trackpad_rotate_view: true, hold_tool_keys: true, + bounding_box_while_moving_and_drawing: false, paste_offset: 10.0, selection_tolerance: 3.0, marquee_selection: "touching".into(), diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 0da75a42ed..d1265172ae 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -723,7 +723,7 @@ struct Host { /// order: the error's mark ([`HostError::Marked`]) is its index. eval_failures: RefCell, u32, u32)>>, /// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and - /// [`vectorcraft_js::UNKNOWN_SOURCE`] before. + /// [`vectorcraft_js::UNKNOWN_SOURCE`] before or when the script did not compile. script_tag: Cell, /// The run's name as its report gives it, which [`Host::name`] shortens for console lines. report_name: Arc, @@ -1865,7 +1865,10 @@ impl Session { .and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() }) .and_then(|()| { let r = realm.start(&expanded.source); - host.script_tag.set(realm.script_tag()); + // Source that does not compile defines no code, so its map is not kept. + if !matches!(&r, Err(e) if e.kind == RunErrorKind::Syntax) { + host.script_tag.set(realm.script_tag()); + } r }) .map_err(|e| located(&host, &realm, &e)) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 887c740936..1eed4a083c 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,8 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + arrowheads: Default::default(), + shown_library_ids: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index c7d22ab6c7..61a4cc8a01 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -267,6 +267,7 @@ impl Session { isolation: st.isolation, perspective_again: None, copies: None, + move_box: std::sync::OnceLock::new(), }); Ok(()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3363bb4a0c..18eadcdf80 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9,6 +9,7 @@ pub(super) mod adjust; mod ai_open_path; mod appearance; mod areafit; +mod arrowheads; mod assets; mod assets_3d; mod attributes; diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs new file mode 100644 index 0000000000..b350341767 --- /dev/null +++ b/crates/engine/src/tests/arrowheads.rs @@ -0,0 +1,365 @@ +//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed, +//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use +//! them. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration}; +use vectorcraft_geom::{PathData, Point, SubPath}; + +use crate::cmd::arrowheads::{self, heads_of}; +use crate::*; + +fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node { + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); +} + +fn filled() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0) +} + +/// A folder of its own for test `name`, emptied. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session_with_line() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn stroke(s: &Session, id: NodeId) -> StrokeLayer { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone() +} + +#[test] +fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() { + let mut d = Document::new(100.0, 100.0); + // 20 high, so a unit is 5 pt: tip on the right edge's middle. + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + d.set_symbol_registration("Wedge", SymbolRegistration(5)); + // A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default). + let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt; + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line); + add_symbol(&mut d, "Band", band); + // Neither filled nor stroked: no head. + add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default())); + // Far longer than wide. + add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled())); + // A fill at 0 % opacity paints nothing. + let mut clear = filled(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() { + f.opacity = 0.0; + } + add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear)); + // More anchors than a symbol's art may have: skipped before its shapes are united. + let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect(); + add_symbol(&mut d, "Dense", polygon(&dense, filled())); + // A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head. + let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true })); + // An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long. + let mut grown = filled(); + grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown)); + // The same effect on the fill alone. + let mut fill_grown = filled(); + fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown)); + // And on a group holding the bar. + let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]); + group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Group Grown", group); + // A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip. + let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true })); + // A group's own fill paints its unpainted 8 × 4 member. + let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]); + painted_group.appearance = filled().into(); + add_symbol(&mut d, "Group Fill", painted_group); + // A 10 × 4 band stroked with a gradient along it. + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut along = Appearance::basic(Paint::None, gradient, 4.0); + if let Some(st) = along.stroke_mut() { + (st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along); + } + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along); + add_symbol(&mut d, "Gradient Band", band); + // An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head. + let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2); + let points = corners + .iter() + .map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() }) + .collect(); + let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points }; + add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh))); + // An effect on the file's layer is not part of any symbol. + Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect { + id: "path.offsetPath".into(), + params: json!({"offset": 5}), + visible: true, + }); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]); + let b = read.heads[9].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}"); + let b = read.heads[7].path.bounds().unwrap(); + assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}"); + let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap()); + let near = |a: f64, b: f64| (a - b).abs() < 1e-3; + assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}"); + let framed = read.heads[6].path.to_bezpath(); + let b = vectorcraft_geom::Shape::bounding_box(&framed); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}"); + assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle"); + for h in &read.heads[3..6] { + let b = h.path.bounds().unwrap(); + assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name); + } + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped); + let b = read.heads[2].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}"); + let b = read.heads[0].path.bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}"); + assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6); + let b = read.heads[1].path.bounds().unwrap(); + assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() { + let dir = folder("edit"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, id) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str()))); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet"); + let line_doc = s.doc().unwrap().uid; + let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap(); + assert_eq!(r["created"], true); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document"); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead"); + // A second head drawn in the file is listed once the file is saved. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled())); + assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1); + s.execute("document.save", &json!({})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead", "Square"]); + // Editing again from the line's document brings the open file to the front, without making + // it anew or opening it twice. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().uid, line_doc); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str()))); + // The line takes the head with its art. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap(); + let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(c.name, "Square"); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err()); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err()); + // A head given with its art is refused when the art could not be drawn. + let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap(); + far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err()); + let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap(); + empty["Custom"]["path"] = json!({"subpaths": []}); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err()); + // One anchor, or anchors on one line, fill nothing. + for pts in [vec![Point::new(1.0, 1.0)], vec![Point::new(0.0, 0.0), Point::new(2.0, 0.0), Point::new(4.0, 0.0)]] { + let mut flat = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Flat".into(), PathData::default())))).unwrap(); + flat["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&pts, false))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": flat})).is_err(), "{pts:?}"); + } + // A lone anchor draws no segment, whatever its handles. + let mut lone = vectorcraft_geom::Anchor::corner(Point::new(1.0, 0.0)); + (lone.h_in, lone.h_out) = (Point::new(0.0, 1.0), Point::new(2.0, -1.0)); + let mut handles = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Lone".into(), PathData::default())))).unwrap(); + handles["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::new(vec![lone], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles.clone()})).is_err(), "a lone anchor"); + // Two equal squares winding opposite ways cancel out. + let sq = [Point::new(0.0, -1.0), Point::new(2.0, -1.0), Point::new(2.0, 1.0), Point::new(0.0, 1.0)]; + let back: Vec = sq.iter().rev().copied().collect(); + let cancel = PathData::new(vec![SubPath::polyline(&sq, true), SubPath::polyline(&back, true)]); + handles["Custom"]["path"] = serde_json::to_value(cancel).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles})).is_err(), "opposite squares"); + // A bow tie's lobes wind opposite ways, so its signed area is 0, and it still fills both. + let tie = [Point::new(0.0, -1.0), Point::new(2.0, 1.0), Point::new(2.0, -1.0), Point::new(0.0, 1.0)]; + let mut bow = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Bow".into(), PathData::default())))).unwrap(); + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&tie, true))).unwrap(); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was"); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow.clone()})).unwrap(); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Bow")); + // So does a very thin bar. + let bar = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 0.001), Point::new(0.0, 0.001)]; + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&bar, true))).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow})).unwrap(); + let Some(Arrowhead::Custom(h)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(h.path.anchor_count(), 4, "the thin bar"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() { + let (mut s, id) = session_with_line(); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0))); + // As document.inspect reports it, so it reads back unchanged. + s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap(); + assert_eq!(stroke(&s, id).start_arrow, Some(head.clone())); + let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string(); + assert!(inspect.contains("\"Wedge\""), "inspect names the head"); + let ink = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + let with_head = ink(&s); + // Saved and opened in a session that has no arrowheads file: the head is drawn the same. + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + assert_eq!(stroke(&o, id).start_arrow, Some(head)); + assert_eq!(ink(&o).pixels, with_head.pixels); + // SVG and PDF write it as they write the built-in heads. + let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len(); + let headed = svg(&mut o); + o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap(); + assert!(headed > svg(&mut o), "the head is in the SVG"); + // Undo puts it back. + o.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_)))); +} + +#[test] +fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { + let dir = folder("bad"); + let file = dir.join(arrowheads::FILE_NAME); + std::fs::write(&file, b"not a drawing").unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty()); + assert!(list["error"].is_string(), "{list}"); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + // A folder in its place is not read either. + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + // Without a file (the web, runs without preferences) there is nothing to edit. + let mut bare = Session::new(); + assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err()); + assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); +} + +#[test] +fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() { + let dir = folder("saved"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, _) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true); + // A speck that saving rounds to a point is a head only before the save. + let st = s.doc_mut().unwrap(); + add_symbol(Arc::make_mut(&mut st.doc), "Speck", polygon(&[(0.0, 0.0), (0.3, 0.1), (0.0, 0.2)], filled())); + assert!(heads_of(&s.doc().unwrap().doc).heads.iter().any(|h| h.name == "Speck"), "a head before rounding"); + s.execute("document.save", &json!({"precision": 0})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead"]); + assert_eq!(list["skipped"][0]["name"], "Speck", "{list}"); + // Editing again leaves the file as it is. + let saved = std::fs::read(&file).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!(std::fs::read(&file).unwrap(), saved, "the file is left as it was"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_arrowheads_file_that_unpacks_past_the_size_limit_is_not_read() { + let dir = folder("unpacked"); + let file = dir.join(arrowheads::FILE_NAME); + let mut d = arrowheads::starter(); + d.title = "a".repeat(arrowheads::MAX_FILE_BYTES as usize); + let bytes = vectorcraft_format::save_with(&d, &vectorcraft_format::SaveOptions { compress: true, ..Default::default() }).unwrap(); + assert!((bytes.len() as u64) < arrowheads::MAX_FILE_BYTES / 16, "compressed small: {}", bytes.len()); + std::fs::write(&file, &bytes).unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty(), "{}", list["arrowheads"]); + assert!(list["error"].is_string(), "{}", list["error"]); + // The same head in a file of an ordinary size is read. + std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.reload", &json!({})).unwrap()["count"], 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() { + let wedge = || Arc::new(polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + // Too far out for the shape operations to measure: the union would leave it out. + let far = || Arc::new(polygon(&[(0.0, 0.0), (1e200, 0.0), (0.0, 1e200)], filled())); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", (*wedge()).clone()); + add_symbol(&mut d, "Broken", Node::group(NodeId(0), vec![wedge(), far()])); + // The same as clipped art. + let mut mask = polygon(&[(0.0, -10.0), (20.0, -10.0), (20.0, 10.0), (0.0, 10.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + add_symbol(&mut d, "Broken Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), wedge(), far()], clip: true })); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge"]); + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped); +} + +#[test] +fn a_group_used_as_the_clipping_path_adds_none_of_its_own_fills() { + // A group holding a filled 20 × 4 bar clips a filled 4 × 4 square: the group paints nothing as + // the clipping path, so the head is the square alone. + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + let mask = Node::group(NodeId(0), vec![Arc::new(bar)]); + let square = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled()); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Group Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(square)], clip: true })); + let read = heads_of(&d); + assert_eq!(read.heads.len(), 1, "{:?}", read.skipped); + let b = read.heads[0].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the square alone: {b:?}"); +} diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 1893f8c186..0d9188243c 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -375,3 +375,72 @@ fn the_selection_tool_reports_a_transforming_drag() { assert!(s.tool_busy() && !s.tool_transforming()); s.pointer(&ev(PointerKind::Up, 550.0, 450.0), v).unwrap(); } + +/// With General › Show Bounding Box While Moving and Drawing on, a move drag's bounding box is the +/// box measured once, at the drag's start, offset by each step's move: with Real-time Drawing on +/// and off, the later steps do not measure the selection again. Off, only the Selection tool with +/// Real-time Drawing off gives a box (the art waits for the release). +#[test] +fn a_move_drag_measures_the_bounding_box_once() { + for (real_time, while_moving) in [(true, true), (false, true), (true, false), (false, false)] { + let what = format!("real time {real_time}, box while moving {while_moving}"); + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + sel(&mut s, &[id]); + s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + s.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": while_moving})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y).with_mods(Mods::default()); + s.pointer(&ev(PointerKind::Down, 150.0, 125.0), v).unwrap(); + assert_eq!(s.move_drag_box(), None, "{what}: not before the drag begins"); + s.pointer(&ev(PointerKind::Drag, 170.0, 135.0), v).unwrap(); + if real_time && !while_moving { + assert_eq!(s.move_drag_box(), None, "{what}: no box during the move"); + s.pointer(&ev(PointerKind::Up, 170.0, 135.0), v).unwrap(); + continue; + } + assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(120.0, 110.0, 220.0, 160.0))), "{what}"); + // The measurement made at the drag's start is the one each step offsets: replaced by a + // stand-in, the next step's box is the stand-in moved. + let it = s.doc_mut().unwrap().interaction.as_mut().unwrap(); + assert_eq!(it.move_box.get(), Some(&Some(OrientedBox::aligned(Rect::new(100.0, 100.0, 200.0, 150.0)))), "{what}: measured at the start"); + it.move_box = std::sync::OnceLock::from(Some(OrientedBox::aligned(Rect::new(0.0, 0.0, 10.0, 10.0)))); + s.pointer(&ev(PointerKind::Drag, 200.0, 165.0), v).unwrap(); + assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(50.0, 40.0, 60.0, 50.0))), "{what}: not measured again"); + s.pointer(&ev(PointerKind::Up, 200.0, 165.0), v).unwrap(); + assert_eq!(s.move_drag_box(), None, "{what}: none after the release"); + assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(150.0, 140.0, 250.0, 190.0)), "{what}: moved"); + } +} + +/// A shape being drawn has no preview applied after a step that failed (with Real-time Drawing on, +/// also when the failed command left a document other than the interaction's snapshot), and with +/// Real-time Drawing off, where the document stays at the snapshot. A successful step with Real-time +/// Drawing on applies it. +#[test] +fn a_shape_being_drawn_reports_whether_a_preview_is_applied() { + for real_time in [true, false] { + let what = format!("real time {real_time}"); + let mut s = session(); + s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 140.0), v).unwrap(); + assert_eq!(s.interaction_unapplied(), !real_time, "{what}: after a step that succeeded"); + assert!(s.preview("shape.rectangle", &json!({"x": "left"})).is_err(), "{what}: the preview fails"); + assert!(s.interaction_unapplied(), "{what}: after a step that failed"); + if real_time { + // A failed command that copied the document on write leaves another document than the + // snapshot; no preview is applied all the same. + let st = s.doc_mut().unwrap(); + st.doc = std::sync::Arc::new((*st.doc).clone()); + assert!(s.interaction_unapplied(), "{what}: a failed step with a copied document"); + } + s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 150.0), v).unwrap(); + assert_eq!(s.interaction_unapplied(), !real_time, "{what}: the next step succeeded"); + s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 150.0), v).unwrap(); + assert!(!s.interaction_unapplied(), "{what}: no interaction after the release"); + } +} diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 4b391c5714..93f531d1e8 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -405,6 +405,143 @@ fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse( } } +/// `object.blend.setEasing` sets one blend's easing and color shift, records no undo step when +/// nothing changes, and refuses bad values, a blend whose steps follow no spine (easing only), a +/// hidden or locked blend and a target that is not a blend, leaving the blend and the history alone. +#[test] +fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() { + 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})).unwrap()); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let d0 = depth(&s); + // Align to Page along the straight lines between the keys: the steps follow no spine. + for bad in [json!({"ramp": 30}), json!({"easingType": "easeOut"})] { + let e = s.execute("object.blend.setEasing", &bad).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + } + assert_eq!(depth(&s), d0); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!(r, json!({"id": g.0, "easingType": "easeIn", "ramp": 0.0, "colorShift": 25.0, "changed": true})); + assert_eq!((spec(&s, g).1.color_shift, depth(&s)), (25.0, d0 + 1)); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!((&r["changed"], depth(&s)), (&json!(false), d0 + 1), "an unchanged value records no undo step"); + // Along the spine each of the four types keeps its kind through a ramp change and its undo. + s.execute("object.blend.options", &json!({"orientation": "path"})).unwrap(); + for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] { + s.execute("object.blend.setEasing", &json!({"easingType": kind, "ramp": 60})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"ramp": 20})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["colorShift"]), (&json!(kind), &json!(20.0), &json!(25.0))); + s.execute("edit.undo", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift), (kind, 0.6, 25.0)); + } + let before = spec(&s, g).1; + let d1 = depth(&s); + for bad in [ + json!({"ramp": 101}), + json!({"ramp": -1}), + json!({"ramp": "x"}), + json!({"colorShift": 1e308}), + json!({"colorShift": -101}), + json!({"easingType": "sideways"}), + json!({"id": "abc", "ramp": 10}), + json!({"id": a.0, "ramp": 10}), + ] { + assert!(s.execute("object.blend.setEasing", &bad).is_err(), "{bad}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before.clone(), d1), "{bad}"); + } + // An explicit id works with nothing selected; nothing selected and no id is an error. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("object.blend.setEasing", &json!({"ramp": 10})).is_err()); + s.execute("object.blend.setEasing", &json!({"id": g.0, "ramp": 10})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.1); + // A hidden or locked blend is refused. + for hide in [true, false] { + s.edit("state", |d, _| { + let n = d.node_mut(g).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + Ok(()) + }) + .unwrap(); + let (before, d2) = (spec(&s, g).1, depth(&s)); + let e = s.execute("object.blend.setEasing", &json!({"id": g.0, "colorShift": 5})).unwrap_err().to_string(); + assert!(e.contains("the object is hidden or locked"), "{e}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before, d2)); + } + // A blend made without a spine takes no easing, Align to Path included; its color shift still + // changes. + let (c, d) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0)); + sel(&mut s, &[c, d]); + let n = id_of(&s.execute("object.blend.make", &json!({"createSpine": "never", "orientation": "path"})).unwrap()); + let d3 = depth(&s); + let e = s.execute("object.blend.setEasing", &json!({"id": n.0, "ramp": 30})).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + assert_eq!(depth(&s), d3); + s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap(); + assert_eq!(spec(&s, n).1.color_shift, 10.0); + // Replace Spine gives it a spine, and it takes easing from then on. + let path = id_of(&s.execute("path.create", &json!({"d": "M0 320 L200 320"})).unwrap()); + sel(&mut s, &[n, path]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"id": n.0, "easingType": "easeOut", "ramp": 30})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["changed"]), (&json!("easeOut"), &json!(30.0), &json!(true))); +} + +/// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a +/// drag that ends at its starting value and a drag canceled with Escape record none, and Escape +/// puts the value back. +#[test] +fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() { + 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, "orientation": "path", "easingType": "easeOut"})).unwrap()); + 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(); + }; + s.select_tool("selection", v).unwrap(); + sel(&mut s, &[g]); + let d0 = depth(&s); + // The Easing widget sits 16 px below the spine's halfway point (120, 20). + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 135.0, 36.0); + ptr(&mut s, PointerKind::Drag, 150.0, 36.0); + ptr(&mut s, PointerKind::Up, 150.0, 36.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, 0.0, d0 + 1)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected"); + // A click, and a drag back to where it started. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 160.0, 36.0); + ptr(&mut s, PointerKind::Drag, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // Escape during a drag puts the value back. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 170.0, 36.0); + assert_eq!(spec(&s, g).1.ease.ramp, 0.8, "the drag previews"); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + ptr(&mut s, PointerKind::Up, 170.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // The Blend tool drags the Appearance widget, 16 px above: the color shift alone. + s.select_tool("blend", v).unwrap(); + sel(&mut s, &[g]); + ptr(&mut s, PointerKind::Down, 120.0, 4.0); + ptr(&mut s, PointerKind::Drag, 100.0, 4.0); + ptr(&mut s, PointerKind::Up, 100.0, 4.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, -20.0, d0 + 2)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.color_shift, 0.0); +} + #[test] fn native_blend_easing_values_clamp_and_non_finite_become_default() { for (bad, ramp, shift) in [ diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 9a3a18ed91..2d363d18cf 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -106,7 +106,17 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); assert_eq!( ids, - ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"] + [ + "art-ribbons", + "ink-and-wash", + "calligraphic-nibs", + "bristle-brushes", + "pattern-borders", + "scatter-shapes", + "game-trails", + "energy-beams", + "dry-media" + ] ); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); @@ -120,26 +130,28 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[6]["count"].as_u64(), Some(4)); + assert_eq!(libs[7]["count"].as_u64(), Some(4)); let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"})); assert_eq!( dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::>(), [("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")] ); - assert_eq!((libs[7]["name"].as_str(), libs[7]["count"].as_u64()), (Some("Dry Media"), Some(4))); + assert_eq!((libs[8]["name"].as_str(), libs[8]["count"].as_u64()), (Some("Dry Media"), Some(4))); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); assert_eq!(strs(&added["added"]), ["Laser Bolt"]); - // One kind of brush per library, every brush drawing something. + // One kind of brush per library (Ink and Wash: art brushes and a calligraphic one), every + // brush drawing something. for (id, kind) in [ ("art-ribbons", "art"), + ("ink-and-wash", "art"), ("calligraphic-nibs", "calligraphic"), ("bristle-brushes", "bristle"), ("pattern-borders", "pattern"), @@ -151,6 +163,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); for b in &lib.brushes { + let kind = if b.name == "Brush Pen" { "calligraphic" } else { kind }; assert_eq!(b.kind.type_id(), kind, "{}", b.name); let mut line = vectorcraft_geom::BezPath::new(); line.move_to((0.0, 0.0)); @@ -176,6 +189,153 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Every path of brush art `n`: (anchors, opacity). +fn art_paths(n: &vectorcraft_doc::Node) -> Vec<(usize, f32)> { + let mut out = vec![]; + n.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.push((p.anchor_count(), c.opacity)); + } + }); + out +} + +/// The Ink and Wash library, made once. +fn inks(s: &Session) -> std::sync::Arc { + cmd::brushlib::library(s, "ink-and-wash").unwrap().1 +} + +/// A straight line from the origin to (`x`, 0). +fn line_to(x: f64) -> vectorcraft_geom::BezPath { + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((x, 0.0)); + line +} + +fn black_stroke() -> vectorcraft_doc::StrokeLayer { + vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) +} + +/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two +/// translucent washes, with names of their own and a few paths of art each. +#[test] +fn the_ink_and_wash_library_has_tapered_ink_a_brush_pen_and_two_washes() { + let mut s = session(); + let lib = run(&mut s, "brush.library", json!({"library": "ink-and-wash"})); + assert_eq!((lib["name"].as_str(), lib["category"].as_str()), (Some("Ink and Wash"), Some("builtIn"))); + let listed: Vec<(&str, &str)> = + lib["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect(); + assert_eq!(listed, [("Tapered Ink", "art"), ("Brush Pen", "calligraphic"), ("Soft Wash", "art"), ("Pooling Wash", "art")]); + assert_eq!(run(&mut s, "brush.library", json!({"library": "Ink and Wash"}))["id"], "ink-and-wash"); + let inks = inks(&s); + // Names no other built-in library uses. + for b in cmd::brushlib::BUILTIN.iter().filter(|b| b.id != "ink-and-wash") { + let (_, other) = cmd::brushlib::library(&s, b.id).unwrap(); + assert!(inks.brushes.iter().all(|x| other.brush(&x.name).is_none()), "{}", b.id); + } + // The art: within its 100 pt length, a few paths each, opaque ink and translucent washes. + let mut anchors = 0; + for name in ["Tapered Ink", "Soft Wash", "Pooling Wash"] { + let BrushKind::Art(a) = &inks.brush(name).unwrap().kind else { panic!("{name} is not an art brush") }; + assert_eq!((a.scale, a.colorization), (vectorcraft_brush::ArtScale::Stretch, vectorcraft_brush::Colorization::Tints), "{name}"); + let b = a.art.geometric_bounds().unwrap(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{name}"); + assert!(b.x0 >= 0.0 && b.x1 <= 100.0 && b.x1 - b.x0 > 99.0, "{name}: {b:?}"); + assert!(b.y0 >= -8.0 && b.y1 <= 8.0 && b.y1 - b.y0 > 5.0, "{name}: {b:?}"); + let paths = art_paths(&a.art); + let count: usize = paths.iter().map(|p| p.0).sum(); + assert!(paths.len() <= 80 && count <= 500, "{name}: {} paths, {count} anchors", paths.len()); + anchors += count; + let opacities: Vec = paths.iter().map(|p| p.1).collect(); + if name == "Tapered Ink" { + assert_eq!(opacities, [1.0], "opaque ink"); + } else { + assert!(paths.len() >= 3, "{name}: a wash of several layers"); + assert!(opacities.iter().all(|o| (0.15..=0.55).contains(o)), "{name}: {opacities:?}"); + } + } + assert!(anchors <= 1200, "{anchors} anchors"); + let BrushKind::Calligraphic(pen) = &inks.brush("Brush Pen").unwrap().kind else { panic!("Brush Pen is not calligraphic") }; + use vectorcraft_brush::Variation::{Fixed, Pressure}; + assert_eq!((pen.size, pen.modes(), pen.variation), (5.0, [Fixed, Fixed, Pressure], [0.0, 0.0, 2.5]), "no random variation"); +} + +/// Every Ink and Wash brush draws along straight, curved, short and tight paths within finite +/// bounds, and Tapered Ink is broad near its start and fine at its end. +#[test] +fn ink_and_wash_brushes_draw_along_straight_curved_short_and_tight_paths() { + let inks = inks(&Session::new()); + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((0.0, 0.0)); + curve.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0)); + let tight = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 6.0, 6.0)).to_bezpath(); + let paths = [line_to(200.0), line_to(3.0), curve, tight]; + for b in &inks.brushes { + for line in &paths { + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, b, line, &black_stroke()); + assert!(!pieces.is_empty(), "{} draws nothing", b.name); + let bounds: Vec<_> = pieces.iter().map(|p| p.geometric_bounds()).collect(); + assert!(bounds.iter().all(|r| r.is_some_and(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))), "{}", b.name); + } + } + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, inks.brush("Tapered Ink").unwrap(), &line_to(100.0), &black_stroke()); + let widest = |from: f64, to: f64| { + pieces + .iter() + .filter_map(|n| n.path_data()) + .flat_map(|p| p.anchors().map(|(_, _, a)| a.p).collect::>()) + .filter(|p| p.x >= from && p.x <= to) + .map(|p| p.y.abs()) + .fold(0.0, f64::max) + }; + assert!(widest(15.0, 30.0) > 4.0 * widest(95.0, 100.0), "broad start, fine tip"); +} + +/// Ink and Wash brushes are added and applied in one step, undone and redone, not added twice, +/// and kept with the brushed stroke by a saved and reopened document. +#[test] +fn ink_and_wash_brushes_add_apply_undo_and_survive_a_saved_document() { + let mut s = session(); + let inks = inks(&s); + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let (defaults, before) = (brushes(&s), undo_len(&s)); + let names = ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]; + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash", "name": "Soft Wash", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone(), undo_len(&s)), (vec!["Soft Wash"], json!([path]), before + 1)); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (defaults.clone(), None)); + run(&mut s, "edit.redo", json!({})); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!( + (strs(&r["names"]), strs(&r["existing"]), strs(&r["added"])), + (names.to_vec(), vec!["Soft Wash"], vec!["Tapered Ink", "Brush Pen", "Pooling Wash"]) + ); + let before = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!((strs(&r["existing"]), undo_len(&s)), (names.to_vec(), before)); + assert_eq!(brushes(&s).len(), defaults.len() + 4); + for n in names { + assert_eq!(&brush(&s, n), inks.brush(n).unwrap(), "{n}"); + } + + // Saved and opened again, the document keeps the definitions and the brushed stroke. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let reopened = vectorcraft_format::load(&vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + for n in names { + assert_eq!(vectorcraft_brush::find(&reopened, n).as_ref(), inks.brush(n), "{n}"); + } + let n = reopened.node(NodeId(path)).unwrap(); + assert_eq!(n.appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Soft Wash")); + assert_eq!( + vectorcraft_brush::node_pieces(&reopened, n).map(|p| p.len()), + vectorcraft_brush::node_pieces(doc(&s), doc(&s).node(NodeId(path)).unwrap()).map(|p| p.len()) + ); +} + /// Whether `p` lies inside the polygon through `pts` (even-odd ray cast). fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool { let mut odd = false; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 0fc2bcb476..49a806b258 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -793,8 +793,9 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect(); assert_eq!(labels, ["Move", "Shape Builder"]); }), - // The proof views redraw every document (a new revision); they are left off, as the - // other tests of the process-wide proof view expect. + // The proof views apply the merge first by command id (`WAIT_FOR_TOOL_EDIT`), also when + // the view does not change and no document gets a new revision. They are left off, as + // the other tests of the process-wide proof view expect. ("Separations Preview", |s, _, _| { s.execute("view.separationsPreview", &json!({"on": false})).unwrap(); }), @@ -807,6 +808,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() { ("Proof Setup", |s, _, _| { s.execute("view.proofSetup", &json!({})).unwrap(); }), + ("a proof view that changes", |s, _, _| { + let _v = crate::tests_colormgmt::DefaultView::new(); + s.execute("view.proofColors", &json!({"on": true})).unwrap(); + let on = s.doc().unwrap().revision; + s.execute("view.proofColors", &json!({"on": false})).unwrap(); + assert!(s.doc().unwrap().revision > on, "the view changes and the documents get new revisions"); + }), ("a preference", |s, _, _| { let every = s.prefs.gridline_every; s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap(); diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index d0901f30a0..493f569805 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -308,6 +308,23 @@ fn assign_profile_is_stored_and_undoable() { assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (None, None)); } +/// Assigning the working profiles leaves the active color settings and the other documents' +/// revisions unchanged. +#[test] +fn assigning_the_working_profiles_keeps_the_active_color_settings() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let working = cms::active_settings(); + let active = cms::active(); + let other = s.documents()[0].revision; + let r = s.execute("edit.assignProfile", &json!({"rgb": working.rgb, "cmyk": working.cmyk})).unwrap(); + assert_eq!((r["rgb"].as_str(), r["cmyk"].as_str()), (Some(working.rgb.as_str()), Some(working.cmyk.as_str()))); + assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (Some(working.rgb.clone()), Some(working.cmyk.clone()))); + assert!(std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were replaced"); + assert_eq!(s.documents()[0].revision, other, "the other document's revision changed"); +} + #[test] fn proof_colors_simulate_press_on_screen() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); @@ -438,6 +455,7 @@ fn overprint_black_multiplies_in_preview() { #[test] fn view_toggles_redraw_without_dirtying() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); let mut s = session(); let rev = s.doc().unwrap().revision; let r = s.execute("view.proofColors", &json!({"on": true})).unwrap(); @@ -458,6 +476,101 @@ fn view_toggles_redraw_without_dirtying() { s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); } +/// Sets the default proof view when made and again when dropped, also after a failed assertion, +/// so the next test starts from the default view. +pub(crate) struct DefaultView; + +impl DefaultView { + pub(crate) fn new() -> Self { + vectorcraft_render::proof::set_view(Default::default()); + DefaultView + } +} + +impl Drop for DefaultView { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + } +} + +/// Runs `cmd` with `p` twice from the default proof view: the first run changes the view and +/// redraws, and the second asks for the view that is already showing and leaves the revision alone. +fn repeat_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + let changed = s.doc().unwrap().revision; + assert!(changed > rev, "{cmd} {p}: the first run changes the view"); + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, changed, "{cmd} {p}: the same view again"); +} + +/// Runs `cmd` with `p` once from the default proof view, which `p` leaves as it is. +fn default_view_keeps_revision(cmd: &str, p: Value) { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let rev = s.doc().unwrap().revision; + s.execute(cmd, &p).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev, "{cmd} {p}"); + assert_eq!(vectorcraft_render::proof::view(), Default::default(), "{cmd} {p}"); +} + +#[test] +fn proof_setup_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofSetup", json!({"target": "deuteranopia"})); + repeat_keeps_revision("view.proofSetup", json!({"simulatePaper": true, "proof": true})); + default_view_keeps_revision("view.proofSetup", json!({})); + default_view_keeps_revision("view.proofSetup", json!({"target": "workingCmyk", "proof": false})); +} + +#[test] +fn proof_colors_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.proofColors", json!({"on": true})); + default_view_keeps_revision("view.proofColors", json!({"on": false})); +} + +#[test] +fn overprint_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.overprintPreview", json!({"on": true})); + default_view_keeps_revision("view.overprintPreview", json!({"on": false})); +} + +#[test] +fn separations_preview_with_nothing_changed_keeps_the_revision() { + repeat_keeps_revision("view.separationsPreview", json!({"on": true})); + repeat_keeps_revision("view.separationsPreview", json!({"only": "Cyan"})); + repeat_keeps_revision("view.separationsPreview", json!({"plates": ["Magenta", "Black"]})); + default_view_keeps_revision("view.separationsPreview", json!({"on": false})); +} + +/// Separations Preview's visible plates are a set: the same plates in another order, or with one +/// named twice, leave the revision alone, and a plate named twice hides with one `toggle`. +#[test] +fn separations_preview_compares_the_visible_plates_as_a_set() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let _v = DefaultView::new(); + let mut s = session(); + let sep = |s: &mut Session, p: Value| { + s.execute("view.separationsPreview", &p).unwrap(); + s.doc().unwrap().revision + }; + let shown = sep(&mut s, json!({"plates": ["Magenta", "Black"]})); + assert_eq!(sep(&mut s, json!({"plates": ["Black", "Magenta"]})), shown, "another order"); + assert_eq!(sep(&mut s, json!({"plates": ["Magenta", "Black", "Black"]})), shown, "a plate named twice"); + // Cyan hidden and shown again goes to the end of the list; every plate is shown. + sep(&mut s, json!({"on": false})); + sep(&mut s, json!({"on": true})); + sep(&mut s, json!({"toggle": "Cyan"})); + let all = sep(&mut s, json!({"toggle": "Cyan"})); + assert_eq!(sep(&mut s, json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]})), all, "every plate in plate order"); + sep(&mut s, json!({"plates": ["Cyan", "Cyan"]})); + let r = s.execute("view.separationsPreview", &json!({"toggle": "Cyan"})).unwrap(); + assert!(r["plates"].as_array().unwrap().iter().all(|p| p["visible"] == false), "{r}"); +} + #[test] fn proof_setup_simulates_tritanopia() { let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs index 764990b100..1ce5b09e41 100644 --- a/crates/engine/src/tests/css.rs +++ b/crates/engine/src/tests/css.rs @@ -98,6 +98,69 @@ fn export_writes_the_sheet_and_rasterized_art() { assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export"); } +#[test] +fn export_refuses_a_scope_or_path_that_is_not_a_string() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + let refused = |s: &mut Session, p: Value, key: &str| { + let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string(); + assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}"); + }; + for scope in [json!(5), json!(true), json!(["all"]), Value::Null] { + refused(&mut s, json!({"scope": scope}), "scope"); + } + for path in [json!(5), Value::Null] { + refused(&mut s, json!({"path": path}), "path"); + refused(&mut s, json!({"path": path, "scope": "all"}), "path"); + } + // Left out, each keeps its default: the selection (the ellipse), and the CSS as data. + let r = run(&mut s, "css.export", json!({})); + assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}"); + assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2)); + let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string(); + assert!(e.contains("scope must be selection or all (got page)"), "{e}"); +} + +#[test] +fn every_object_refuses_malformed_ids_it_does_not_use() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap(); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] { + for (key, value, says) in [ + ("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""), + ("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"), + ("id", json!(-1), "object ids are non-negative integers, not -1"), + ("id", json!("$step"), "`id` is the step reference \"$step\""), + ] { + let mut p = base.clone(); + p[key] = value; + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string(); + assert!(e.contains(says), "{cmd} {p}: {e}"); + } + // Well-formed ids are still not used: every object. + let mut p = base.clone(); + p["ids"] = json!([rect]); + let rules = run(&mut s, cmd, p)["rules"].clone(); + let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64)); + assert_eq!(count, Some(2), "{cmd}: {rules}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_with_a_scope_that_is_not_a_string_writes_nothing() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("site.css"); + assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err()); + assert!(!path.exists(), "no file for a refused scope"); + std::fs::remove_dir_all(&dir).ok(); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn export_to_a_path_puts_the_pictures_next_to_the_sheet() { diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 435d918552..a0193a27f8 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -631,6 +631,32 @@ fn an_opacity_mask_on_type_bends_as_other_art() { assert_eq!(kept, 1, "the type's stroke stays a stroke"); } +/// An envelope bends the art of a linked opacity mask on its contents with them; an unlinked mask +/// stays where it is. +#[test] +fn an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one() { + for linked in [true, false] { + let mut s = session(); + let a = rect(&mut s); + let m = rect(&mut s); + sel(&mut s, &[a, m]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + if !linked { + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + } + let before = node(&s, a).extras().mask.as_ref().unwrap().art.path_data().cloned(); + sel(&mut s, &[a]); + let e = arch(&mut s, 60.0, json!({})); + let mut found = vec![]; + for n in &evaluated(&s, e) { + n.walk(&mut |c| found.extend(c.extras().mask.as_ref().map(|k| (c.path_data().cloned(), k.art.path_data().cloned())))); + } + let [(bent, mask)] = found.as_slice() else { panic!("one masked object: {found:?}") }; + assert!(bent != &before, "the contents bend"); + assert_eq!(mask, if linked { bent } else { &before }, "linked: {linked}"); + } +} + /// Expand turns an envelope of stroked type into its bent glyph outlines with the stroke on them, /// at its own weight, as it draws (#6644). #[test] diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index 60bf81a060..ac5a494dc7 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -92,7 +92,50 @@ fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2); let r = s.execute("select.globalEdit", &json!({})).unwrap(); assert_eq!(r["active"], false, "the second run is Stop Global Edit"); - assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty()); + assert!(s.doc().unwrap().global_edit.is_none()); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "with the square still selected, the objects it would take are highlighted again"); +} + +#[test] +fn selecting_one_object_highlights_the_similar_objects_before_global_edit_starts() { + use crate::cmd::globaledit::highlights; + let mut s = session(); + let [a, b, _, wide, _] = squares(&mut s); + assert!(highlights(&s).is_empty(), "nothing selected"); + sel(&mut s, &[a]); + assert_eq!(members(&s), None, "Global Edit has not started"); + assert_eq!(highlights(&s).len(), 2, "the copy and the smaller square"); + s.execute("select.globalEditOptions", &json!({"size": true})).unwrap(); + assert_eq!(highlights(&s).len(), 1, "the options narrow them: the copy only"); + s.execute("select.globalEditOptions", &json!({"size": false})).unwrap(); + assert_eq!(highlights(&s).len(), 2); + sel(&mut s, &[b]); + s.execute("edit.clear", &json!({})).unwrap(); + sel(&mut s, &[a]); + assert_eq!(highlights(&s).len(), 1, "a deleted copy is no longer highlighted"); + sel(&mut s, &[wide]); + assert!(highlights(&s).is_empty(), "the rectangle has no similar object"); + sel(&mut s, &[a, wide]); + assert!(highlights(&s).is_empty(), "two objects selected"); +} + +#[test] +fn the_canvas_highlights_similar_objects_before_the_start_with_the_selection_tool_only() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + let outline = node(&s, b).stroke_path().unwrap(); + let shown = |s: &mut Session| { + s.overlays(ViewInfo::default()) + .iter() + .any(|o| matches!(o, vectorcraft_tools::Overlay::Path { path, width, .. } if *path == outline && *width == 1.0)) + }; + sel(&mut s, &[a]); + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(shown(&mut s), "the copy is highlighted with the Selection tool"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + assert!(!shown(&mut s), "not with another tool before Global Edit starts"); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(shown(&mut s), "while Global Edit is on, with any tool"); } #[test] @@ -506,6 +549,7 @@ fn leaving_out_all_but_one_ends_global_edit_and_the_first_object_stays() { let r = s.execute("select.globalEditExclude", &json!({"id": b.0})).unwrap(); assert_eq!(r, json!({"active": false, "count": 0, "ids": []})); assert_eq!(members(&s), None, "one object left: Global Edit ends"); + sel(&mut s, &[wide]); assert!(crate::cmd::globaledit::highlights(&s).is_empty()); } diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index 73e0172da5..a2aea6341d 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -881,3 +881,134 @@ fn making_a_gradient_diamond_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(fill_gradient(&s, id), before); } + +/// `paint.setFill` and `paint.editGradient` take a spread and a count for each kind of gradient +/// that has ends; the params give them back, the native format keeps them, and a change is one +/// undo step while an edit that changes nothing records none. +#[test] +fn a_gradient_takes_a_spread_and_a_count() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + for kind in ["linear", "radial", "conic", "diamond"] { + s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "spread": "Reflect", "repeats": 4}})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Reflect, 4), "{kind}"); + let params = vectorcraft_tools::params::gradient_params(&g); + assert_eq!((params["spread"].as_str(), params["repeats"].as_u64()), (Some("reflect"), Some(4)), "{kind}"); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.spread, GradientSpread::Pad, "a new gradient pads"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient, g.gradient, "{kind}"); + } + let steps = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 2)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing changed: no step"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.repeats, 2, "Pad keeps the count for later"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_gradient(&s, id), g); + 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!("the gradient is lost") }; + assert_eq!((saved.gradient.spread, saved.gradient.repeats), (GradientSpread::Repeat, 2)); + // The params of a gradient that pads give back the count it keeps. + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!((fill_gradient(&s, id).gradient.spread, fill_gradient(&s, id).gradient.repeats), (GradientSpread::Pad, 2), "{params}"); +} + +/// A spread or count a gradient can't take is an error, from every command that sets one, and the +/// gradient stays as it was: an unknown spread, a count that is not a whole number from 1 to 100, +/// and a spread other than pad on a freeform gradient. +#[test] +fn a_bad_spread_or_count_is_refused() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "spread": "repeat", "repeats": 3}})).unwrap(); + let before = fill_gradient(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + for bad in [json!({"spread": "mirror"}), json!({"spread": 1}), json!({"spread": true})] { + let e = s.execute("paint.editGradient", &bad).unwrap_err().to_string(); + assert!(e.contains("`spread` must be pad, repeat or reflect"), "{bad}: {e}"); + } + for n in [json!(0), json!(-2), json!(1.5), json!(101), json!(1e9), json!("3"), json!(f64::MAX)] { + let e = s.execute("paint.editGradient", &json!({ "repeats": n })).unwrap_err().to_string(); + assert!(e.contains("`repeats` must be a whole number from 1 to 100"), "{n}: {e}"); + let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "repeats": n}})).unwrap_err().to_string(); + assert!(e.contains("`repeats`"), "{n}: {e}"); + } + let freeform = json!({"kind": "freeform", "spread": "reflect"}); + let e = s.execute("paint.editGradient", &freeform).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to linear, radial, conic and diamond gradients"), "{e}"); + let e = s.execute("paint.setFill", &json!({ "gradient": freeform })).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to"), "{e}"); + assert_eq!(fill_gradient(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // Pad on a freeform gradient is allowed (it has nothing to spread), and 100 is the most. + s.execute("paint.editGradient", &json!({"repeats": 100})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform", "spread": "pad"})).unwrap(); + // A repeating gradient turned freeform: its params, which carry no spread, set it again. + s.execute("paint.editGradient", &json!({"kind": "radial", "spread": "reflect"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + assert!(params.get("spread").is_none(), "{params}"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); +} + +/// A spread for a selected gradient applies while the gradient used last, the default, is a +/// freeform one, which keeps no spread. +#[test] +fn a_spread_for_a_selected_gradient_leaves_a_freeform_default_alone() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform"}})).unwrap(); + s.execute("paint.editGradient", &json!({"ids": [a.0], "spread": "repeat", "repeats": 3})).unwrap(); + let g = fill_gradient(&s, a); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 3)); +} + +/// A preset keeps its spread and count: saved from a gradient, applied to another object. +#[test] +fn a_gradient_preset_keeps_its_spread() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "spread": "reflect", "repeats": 5}})).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Fan"})).unwrap(); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("gradient.presets.apply", &json!({"name": "Fan"})).unwrap(); + let g = fill_gradient(&s, b); + assert_eq!((g.gradient.kind, g.gradient.spread, g.gradient.repeats), (GradientKind::Conic, GradientSpread::Reflect, 5)); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); +} + +/// A gradient set back to Pad keeps its count for later, and still matches a saved preset and a +/// swatch of the same gradient; while it repeats, the spread tells them apart. +#[test] +fn a_padding_gradient_matches_whatever_count_it_keeps() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let ramp = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("paint.setFill", &json!({ "gradient": ramp })).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Plain"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Plain", "paint": {"gradient": ramp}})).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().contains(&json!("Plain")); + assert!(!unused(&mut s)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 5})).unwrap(); + assert_eq!(s.active_saved_gradient_preset(), None, "a repeating gradient is another ramp"); + assert!(unused(&mut s), "nothing paints the swatch's gradient"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); + assert_eq!(s.active_saved_gradient_preset(), Some("Plain")); + assert!(!unused(&mut s), "the gradient paints as the swatch does"); +} diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 30a8776b35..5ce4a3757e 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -214,26 +214,72 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let mine = format!("loaded/{}", text(&inside)); let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine}))); assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}"); - assert_eq!(copied["path"], Value::Null, "{copied}"); + assert_eq!(copied.get("path"), None, "{copied}"); let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"}))); assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}"); for reply in [&added, &copied, &saved] { assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}"); } - // A library loaded from data under the file name keeps its id; each concealed one takes the next - // free one, so no two rows share an id. + // A library loaded from data under the file name keeps its id; the concealed one shown under that + // id takes the next free one, the other keeps its id, and no two rows share an id. let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string(); run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"})); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}"); // Copied into the user library folder, it is still found by that id, which no other library takes. confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"}))); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}"); // The person sees the folder. let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"})); assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches")))); let _ = std::fs::remove_dir_all(base); } + +/// Outside Windows a `\` is part of a file name: a concealed library saved at `/weird\name.gpl` +/// is shown under its whole file name. Another platform's absolute path is still named by the part +/// after its last `\`. +#[cfg(not(windows))] +#[test] +fn a_concealed_library_keeps_a_backslash_in_its_file_name() { + let base = folder("backslash-name"); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a"))); + let foreign = r"loaded/C:\Users\someone\other.gpl".to_string(); + let s = Session::new(); + let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()])); + let _ = std::fs::remove_dir_all(base); + hidden.sort(); + let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())]; + want.sort(); + assert_eq!(hidden, want); +} + +/// A concealed library keeps the id a client was shown when another library with the same file name, +/// whose full id sorts before it, is loaded later: the id still finds the first library. +#[test] +fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() { + let base = folder("keeps-id"); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches")); + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + (SWATCHES.add)(&mut s, "First"); + run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"})); + (SWATCHES.add)(&mut s, "Second"); + run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"})); + let shown = "loaded/Kit.vcswatches"; + let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone(); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}"); + run(&mut s, "swatch.library.load", json!({"path": text(&later)})); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + let ids: Vec = loaded(&list).into_iter().map(|(id, ..)| id).collect(); + assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}"); + let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))})); + let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown}))); + assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index f0a0674f08..a3c7a72aee 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -728,6 +728,56 @@ fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() { assert_eq!(link_path(&s, a), one); } +/// With copy, for a client whose read roots don't hold the document's folder: the reply names the +/// copy by its file name, and a copy refused there keeps the write root and names the folder and +/// the file by their names. +#[test] +fn relink_with_copy_names_the_documents_folder_by_its_name_outside_the_read_roots() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-roots"); + let one = dir.file("doc/one.png"); + write(&one, &png(60, 30, RED)); + let mut s = session(); + let a = place(&mut s, &one); + save(&mut s, &dir.file("doc/poster.vectorcraft")); + let x = dir.file("new/x.png"); + write(&x, &png(60, 30, BLUE)); + let (new, doc) = (Path::new(&x).parent().unwrap().to_path_buf(), Path::new(&one).parent().unwrap().to_path_buf()); + let hidden = doc.to_string_lossy().into_owned(); + let p = json!({"ids": [a.0], "path": x, "copy": true}); + + let writes_there = AutomationRoots::new(&[new.as_path()], &[doc.as_path()]).unwrap(); + let r = confine(writes_there.as_ref(), || s.execute("links.relink", &p)).unwrap(); + assert_eq!(r["copied"], json!([{"from": x, "to": "x.png"}]), "{r}"); + assert!(!r.to_string().contains(&hidden), "{r}"); + std::fs::remove_file(dir.file("doc/x.png")).unwrap(); + + let elsewhere = AutomationRoots::new(&[new.as_path()], &[new.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&new).unwrap()).display().to_string(); + let e = confine(elsewhere.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, format!("doc: automation path rejected: outside the write root {root}: x.png")); + assert!(!Path::new(&dir.file("doc/x.png")).exists()); +} + +/// With copy, for a document whose path has no folder: outside the read roots the error does not +/// name the path, and without roots it names it whole. +#[test] +fn relink_with_copy_names_a_document_path_without_a_folder_as_the_request_may_see_it() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-no-folder"); + let mut s = session(); + s.doc_mut().unwrap().path = Some("/".into()); + let p = json!({"path": dir.file("x.png"), "copy": true}); + let roots = AutomationRoots::new(&[dir.0.as_path()], &[dir.0.as_path()]).unwrap(); + let e = confine(roots.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `links.relink`: a file or folder outside the read roots has no folder"); + let whole = s.execute("links.relink", &p).unwrap_err().to_string(); + let doc = crate::cmd::fileio::absolute_path("/"); + assert_eq!(whole, format!("invalid parameters for `links.relink`: {doc} has no folder")); +} + /// A scope or paths that doesn't fit is refused, and nothing is relinked. #[test] fn relink_refuses_scopes_and_paths_that_dont_fit() { diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 4bc58e5f6b..d2e5f1b115 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -233,7 +233,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { saved, [ (1.0, 1.0, 1.0, 0.0, vec![0.0, 0.0, 0.0, 9.0]), - (163830.0, 163830.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]), + (3_999_856.0, 200000.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]), (10.0, 10.0, 1000.0, 0.0, vec![0.0; 4]), ] ); @@ -242,7 +242,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { s.execute("file.new", &json!({"preset": "Low"})).unwrap(); assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 1.0, 1.0)], [0.0, 0.0, 0.0, 9.0])); s.execute("file.new", &json!({"preset": "High"})).unwrap(); - assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 163830.0, 163830.0)], [72.0, 72.0, 72.0, 0.0])); + assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 3_999_856.0, 200000.0)], [72.0, 72.0, 72.0, 0.0])); s.execute("file.new", &json!({"preset": "Many"})).unwrap(); assert_eq!(boards(&s).len(), 1000); let custom = presets(&mut s, "Recent").into_iter().find(|p| p["name"] == "Custom").unwrap(); @@ -250,6 +250,63 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { assert_eq!(boards(&s).iter().map(|r| r.x0).collect::>(), [0.0, 100.0, 200.0], "side by side, not overlapping"); } +/// `file.new` and `artboard.new` take the same largest artboard side (the canvas less the largest +/// bleed on both sides) and refuse a side just past it, or of 0 or less, with an error that names +/// the limit in points and, when the document's units aren't points, in those units too. +#[test] +fn file_new_and_artboard_new_take_the_same_largest_side() { + let max = 3_999_856.0; + let in_meters = "at most 3999856 pt (1411.0603 m)"; + let s = new_doc(json!({"width": max, "height": 170078})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, max, 170078.0)]); + let mut s = Session::new(); + let err = s.execute("file.new", &json!({"width": max + 1.0, "height": 100})).unwrap_err().to_string(); + assert!(err.contains("width and height must be more than 0 and at most 3999856 pt") && !err.contains('('), "{err}"); + let err = s.execute("file.new", &json!({"width": 100, "height": "1412 m", "units": "Meters"})).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{err}"); + // The value in other units is rounded down, so it is one file.new takes. + let err = s.execute("file.new", &json!({"width": 100, "height": "56000 in", "units": "Inches"})).unwrap_err().to_string(); + assert!(err.contains("at most 3999856 pt (55553.5555 in)"), "{err}"); + assert!(s.execute("file.new", &json!({"width": 0, "height": 100})).is_err()); + assert!(s.doc().is_err(), "no document was made"); + s.execute("file.new", &json!({"width": "55553.5555 in", "height": 100, "units": "Inches"})).unwrap(); + + let mut s = new_doc(json!({"units": "Meters"})); + s.execute("artboard.new", &json!({"x": -2_000_000, "y": 0, "width": max, "height": 100})).unwrap(); + assert_eq!(boards(&s)[1], Rect::new(-2_000_000.0, 0.0, max - 2_000_000.0, 100.0)); + for p in [ + json!({"x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100}), + json!({"x": 0, "y": 2000, "width": 100, "height": max + 1.0}), + json!({"width": 0}), + json!({"height": -1}), + ] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2, "the refused artboards were not added"); + // A size taken from a wider artboard is refused too. + s.execute("artboard.setProps", &json!({"index": 1, "x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100})).unwrap(); + for p in [json!({}), json!({"x": 0, "y": 5000, "height": 100}), json!({"side": "bottom", "beside": 1})] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2); + + // A side below 1 pt is kept as given, as file.new keeps it, and a size taken from such an artboard keeps it too. + let mut s = new_doc(json!({"width": 0.5, "height": 0.25})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 0.25)]); + s.execute("artboard.new", &json!({"x": 0, "y": 10, "width": 0.5, "height": 0.75})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 20})).unwrap(); + assert_eq!(boards(&s)[1..], [Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); + // artboard.setProps keeps such sides when it changes something else, on a locked artboard too, + // and still raises a supplied side to 1 pt. + s.execute("artboard.setProps", &json!({"index": 1, "name": "Small"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "name": "Small locked"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "height": 0.5})).unwrap(); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 1.0), Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); +} + #[test] fn saved_presets_live_in_the_preferences() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 84e5559851..07589a2f4a 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { } } +/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the +/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() { + for item in [json!(null), json!(0)] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})), + ] { + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + } + let before = render(&s); + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "item {item}: {diff} pixels differ"); + } +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). @@ -464,6 +485,86 @@ fn a_turned_or_flat_diamond_expands_as_the_canvas_draws_it() { } } +/// A gradient that repeats or reflects expands, into objects or a mesh, as the canvas paints it: +/// its stops repeated across the box, for each kind. +#[test] +fn a_spread_gradient_expands_as_the_canvas_paints_it() { + for kind in ["linear", "radial", "conic", "diamond"] { + for spread in ["repeat", "reflect"] { + for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { + let mut s = session(); + let gr = json!({"kind": kind, "spread": spread, "repeats": 3, "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", to.clone()); + let after = render(&s); + // Where Repeat starts the stops again, the hard edge may land a pixel off. + assert!(mean_diff(&before, &after) < 4.0, "{kind} {spread} {to}: {}", mean_diff(&before, &after)); + } + } + } +} + +/// A gradient that repeats or reflects expands into the same whole number of objects in each run, +/// so each run starts on an object's edge: a sharp step in the middle of each of 7 runs, expanded +/// into about 15 objects (2 a run), lands where the canvas paints it. +#[test] +fn a_spread_gradient_expands_into_whole_bands_for_each_run() { + let stops = json!([ + {"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ffff00"}, + {"offset": 0.5, "color": "#004080"}, {"offset": 1, "color": "#004080"} + ]); + for kind in ["linear", "conic"] { + for spread in ["repeat", "reflect"] { + let mut s = session(); + // A conic gradient turns about the box's center. + let start = if kind == "conic" { [100, 100] } else { [30, 100] }; + let gr = json!({"kind": kind, "spread": spread, "repeats": 7, "start": start, "end": [170, 100], "stops": stops}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", json!({"steps": 15})); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{kind} {spread}: {}", mean_diff(&before, &after)); + } + } +} + +/// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not +/// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of +/// the object (its stroke) still expands. `object.expand.info` reports no gradient or fill to +/// expand, and Expand with only Fill on reports nothing to expand; the same gradient in 70 runs +/// (10 repeats) reports both. +#[test] +fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { + let gradient = |repeats: u32| { + json!({"kind": "linear", "spread": "repeat", "repeats": repeats, "start": [30, 100], "end": [50, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}) + }; + for (repeats, expands) in [(10, true), (100, false)] { + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(repeats), true); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!( + (info["gradient"].clone(), info["fill"].clone(), info["stroke"].clone()), + (json!(expands), json!(expands), json!(true)), + "{repeats}" + ); + let only_fill = s.execute("object.expand", &json!({"object": false, "stroke": false})); + assert_eq!(only_fill.is_ok(), expands, "{repeats}: {only_fill:?}"); + } + for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(100), true); + let before = render(&s); + run(&mut s, "object.expand", to.clone()); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{to}: {}", mean_diff(&before, &after)); + let mut gradients = 0; + s.doc().unwrap().doc.walk(|n| gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_)))); + assert_eq!(gradients, 1, "{to}"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index d3394d1481..a92ca10416 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() { assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did"); } +/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns +/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() { + let effects = [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})), + ]; + for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] { + // Flatten Transparency draws a brushed stroke's drop shadow differently with or without a + // Transform effect, so the brushed stroke is checked with the other two commands. + for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) { + let mut s = session(); + square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects); + if brush { + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}"); + } + } +} + /// The brush art of a brushed stroke with a geometry effect follows the path the effect makes. #[test] fn a_brush_draws_along_the_path_the_stroke_effects_make() { diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index c1da365740..2e57ed048c 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -515,7 +515,7 @@ fn the_document_info_report_has_every_category() { "OBJECTS", "GRAPHIC STYLES", "BRUSHES", - "SPOT COLORS", + "SPOT COLOR OBJECTS", "PATTERN OBJECTS", "GRADIENT OBJECTS", "SYMBOLS", diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 28f81587ab..ac39de0646 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() { st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize]; st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize]; st.miter_limit = 1.0 + next(20) as f64; - let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]); + let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone()); (st.start_arrow, st.end_arrow) = (head(), head()); st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64); if next(3) == 0 { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e7d3c7cd7a..2420ef265c 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1084,6 +1084,111 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); } +/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed +/// document and a linked image outside the read roots are not named by path, and the document +/// keeps its links. +#[test] +fn document_replies_leave_out_paths_outside_the_read_roots() { + let away = Folder::new("doc-lrr-away"); + let home = Folder::new("doc-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let mut s = session(); + let id = place(&mut s, &src); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap(); + let image = r["ids"][0].as_u64().unwrap(); + // A linked JavaScript file outside the read roots, and one given as a URL. + let script = away.file("menu.js"); + write(&script, b"void 0;"); + let url = "https://example.com/scripts/menu.js"; + for file in [script.as_str(), url] { + s.execute("svgInteractivity.addFile", &json!({"file": file})).unwrap(); + } + let poster = away.file("poster.vectorcraft"); + save(&mut s, &poster); + // The folder Export for Screens wrote to, and a pattern of the linked image being edited: its + // definition before editing is kept beside the tile art. + let out = away.file("out"); + s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some())); + let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| { + let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap() + }; + let inspect = ask(&mut s, &home.0, "document.inspect"); + assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}"); + assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}"); + let model = ask(&mut s, &home.0, "document.json").to_string(); + assert!(!model.contains("doc-lrr-away"), "{model}"); + for name in ["logo.vectorcraft", "photo.png"] { + assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}"); + } + assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}"); + assert!(model.contains(&format!("\"svg_scripts\":[\"menu.js\",\"{url}\"]")), "{model}"); + // The document keeps its links, and a caller whose read roots hold the files sees every path. + assert_eq!(placed(&s, id).link.path, src); + assert_eq!(s.doc().unwrap().doc.svg_scripts, [script.as_str(), url]); + for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] { + let model = model.to_string(); + for path in [&src, &pic, &out, &script] { + assert!(model.contains(&json!(path).to_string()), "{path}: {model}"); + } + } + assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster)); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); +} + +/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots +/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a +/// link inside them whose stored relative path leads outside them loses that relative path. +#[test] +fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() { + let away = Folder::new("doc-brr-away"); + let home = Folder::new("doc-brr-home"); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let near_pic = home.file("near.png"); + write(&near_pic, &crate::tests_links::png(20, 10, RED)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + let image = r["ids"][0].as_u64().unwrap(); + let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap(); + save(&mut s, &home.file("poster.vectorcraft")); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap(); + lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic})); + d.update_links(|l| { + if l.name() == "near.png" { + l.relative = Some("../doc-brr-away/near.png".into()); + } + }); + } + let unconfined = s.execute("document.json", &json!({})).unwrap().to_string(); + for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] { + assert!(unconfined.contains(&part), "{part}: {unconfined}"); + } + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap(); + let text = model.to_string(); + assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}"); + assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}"); + assert!(text.contains(&json!(near_pic).to_string()), "{text}"); + // The document keeps its brushes and links. + let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string(); + assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 76df1c2194..75580fb6bc 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -452,6 +452,60 @@ fn a_painted_effect_on_a_fill_and_refused_results() { } } +/// A fill's Drop Shadow before a painted effect that returns two fills, and a 200% Transform effect +/// after it: the shadow of each returned fill follows the Transform effect. The square the effect +/// returns spans 30 to 70 pt once scaled, so both half-opaque shadows, 20 pt to the right, cover +/// 85 pt as they cover 75 pt. +#[test] +fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.shadowedfills", true), &out)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})), + ("plugin.test.engine.shadowedfills", json!({})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200})), + ] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill})); + } + let img = drawn(&s); + let (near, far) = (img.pixel(75, 50), img.pixel(85, 50)); + assert_ne!(near, WHITE, "the shadows reach past the square"); + assert_eq!(far, near, "both shadows at 20 pt"); +} + +/// A Transform effect with Scale Strokes & Effects on after a painted effect on a fill leaves the +/// Drop Shadow after it as it is on every paint the piece is drawn with. +#[test] +fn a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaints", true), &two)); + 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.twopaints", "item": fill})); + let mut n = node(&s, id); + let later = [ + vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"scaleH": 200, "scaleV": 200, "strokes": true}), visible: true }, + vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10.0, "y": 0.0, "blur": 0.0}), visible: true }, + ]; + n.appearance.items[fill].effects_mut().extend(later); + let art = vectorcraft_render::effects::reshape(Some(&s.doc().unwrap().doc), &n, None).unwrap(); + let mut offsets = vec![]; + art.walk(&mut |m| { + for item in &m.appearance.items { + for r in vectorcraft_render::effects::raster_effects(item.effects()) { + if let vectorcraft_render::effects::RasterFx::DropShadow { dx, dy, .. } = r { + offsets.push((dx, dy)); + } + } + } + }); + assert_eq!(offsets, [(10.0, 0.0), (10.0, 0.0)], "one shadow on each of the piece's two fills"); +} + /// A result refused for a swatch the document doesn't have stays cached: once the document has the /// swatch, the next check of that result clears the error. #[test] @@ -505,6 +559,30 @@ fn a_painted_group_effect_links_to_the_document_s_swatches() { assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green"); } +/// A painted effect on a group leaves the group's art as it is when a path in it has a linked +/// opacity mask, which can't take the pieces as the path does: the mask still covers the path. +#[test] +fn a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let m = rect(&mut s, 10.0, "#ffffff"); + run(&mut s, "select.set", json!({"ids": [a.0, m.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + rect(&mut s, 100.0, "#0000ff"); + run(&mut s, "select.all", json!({})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.maskedpaint", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.maskedpaint"})); + let art = vectorcraft_render::effects::evaluate_container(Some(&s.doc().unwrap().doc), &node(&s, g)).unwrap(); + let mut masked = vec![]; + art.walk(&mut |c| masked.extend(c.extras().mask.as_ref().map(|k| (c.geometric_bounds(), k.art.geometric_bounds())))); + assert!(!masked.is_empty(), "the masked path"); + for (path, mask) in masked { + assert_eq!(path, mask, "the mask covers the path"); + } +} + /// The output of a painted effect that returns one green square linked to swatch `Leaf`. fn leaf_square() -> String { let mut leaf = solid("#00ff00"); @@ -644,6 +722,27 @@ fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() { } } +/// `object.expand.info` measures each piece a painted effect returns: a piece whose repeating +/// gradient needs more runs across it than are written (1,000) reports no gradient to expand, as +/// Expand leaves it a gradient, and the same gradient in 10 runs reports one. +#[test] +fn expand_info_measures_the_gradient_on_each_painted_piece() { + for (end, expands) in [(120.0, true), (100.2, false)] { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let gradient = json!({"kind": "linear", "spread": "repeat", "repeats": 10, "start": [100, 50], "end": [end, 50]}); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": gradient})); + let out = json!({"objects": [{"path": square_at(100.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.expandruns", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandruns"})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{end}"); + let only_fill = s.execute("object.expand", &json!({"object": false, "fill": true, "stroke": false})); + assert_eq!(only_fill.is_ok(), expands, "{end}: {only_fill:?}"); + } +} + /// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its /// tiles expanded, and a piece with a paint of its own keeps that paint. #[test] diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index 773b2b7842..e395c708bc 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -581,11 +581,13 @@ fn set_bounds_measures_paths_turned_together_square_to_their_box() { fn a_size_below_what_the_kept_strokes_of_several_paths_add_gives_them_no_width() { // With no width, the paths have a visual box 10 pt wide, the width of the thick stroke. A width // of 0 or more below 10 gives both paths no width, and the left edge of the visual box stays at - // -2. A negative width turns the paths over, as it does one path. + // -2. With no width each path turns back at its top and bottom, where the miter join bevels, + // so the visual box runs from y 100 to 150. A negative width turns the paths over, as it does + // one path. let mut wrong = vec![]; for (width, paths, visual) in [ - (8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)), - (0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)), + (8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)), + (0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 100.0, 8.0, 150.0)), (-37.0, [Rect::new(-44.0, 100.0, 0.0, 150.0), Rect::new(-44.0, 100.0, -13.2, 150.0)], Rect::new(-49.0, 95.0, 1.0, 155.0)), ] { let (mut s, ids) = thin_and_thick(); @@ -666,6 +668,35 @@ fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion( assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}"); } +#[test] +fn set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion() { + // The bevel at the triangle's tip, the miter the limit bevels there and the miter spikes it + // keeps reach past the path by margins that follow the slanted segments' directions, which a + // scale out of proportion changes. The miter spikes of a stroke whose dashes have round or + // projecting caps reach past the path too. + let mut wrong = vec![]; + for (join, limit, cap, dash) in [ + ("bevel", 10.0, "round", Value::Null), + ("miter", 2.0, "round", Value::Null), + ("miter", 10.0, "round", Value::Null), + ("miter", 10.0, "round", json!([6, 3])), + ("miter", 10.0, "projecting", json!([6, 3])), + ] { + let mut s = session(); + let anchors = json!([{"x": 100, "y": 150}, {"x": 110, "y": 100}, {"x": 120, "y": 150}]); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": cap, "join": join, "miterLimit": limit, "dash": dash})); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + if (v.width() - 60.0).abs() > 1e-6 || (v.height() - 20.0).abs() > 1e-6 { + wrong.push(format!("{join} {limit} {cap} {dash}: {v:?}")); + } + } + assert!(wrong.is_empty(), "{wrong:?}"); +} + #[test] fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() { let mut s = session(); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 1f3867a622..976d217833 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1179,6 +1179,31 @@ fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() { assert_eq!(offered(&mut b).len(), 2); } +/// An entry `/keeps-` in a crashed app's area keeps nothing while restore or +/// discard holds that area: both crashed apps' copies are restored, and discarded. +#[test] +fn a_keeps_entry_in_a_crashed_apps_area_held_by_the_same_command_keeps_nothing() { + let crashed = |store: &Arc| { + let mut s = session_in(store); + s.execute("file.recovery.save", &json!({})).unwrap(); + s.recovery.own_area().unwrap().to_string() + }; + for cmd in ["file.recovery.restore", "file.recovery.discard"] { + let store = Arc::new(MemoryStore::default()); + let a = crashed(&store); + let c = crashed(&store); + assert_ne!(a, c); + // The area that sorts first, which the command holds first, keeps the other one's copies. + let (first, second) = if a < c { (a, c) } else { (c, a) }; + store.write(&format!("{first}/keeps-{second}"), b"").unwrap(); + let mut b = relaunch(&store); + assert_eq!(offered(&mut b).len(), 2); + let r = b.execute(cmd, &json!({})).unwrap(); + let done = if cmd == "file.recovery.restore" { restored(r) } else { r["discarded"].as_array().unwrap().len() }; + assert_eq!(done, 2, "{cmd}"); + } +} + /// An entry `/keeps-` in a folder whose lock file can't be opened keeps nothing. #[cfg(unix)] #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 9932055652..5edd0d7fbd 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -84,6 +84,55 @@ fn a_confined_save_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(d); } +/// A save without a path, and Revert, refused because the document's own file is outside a confined +/// client's roots: the error keeps the root and the reason and names the file by its file name, also +/// for the files a save writes beside it. A path the client gives stays whole in the error. +#[test] +fn pathless_save_rejection_hides_document_folder() { + use crate::file_access::{AutomationRoots, confine}; + + let d = dir("roots-error"); + let (inside, outside) = (d.join("in"), d.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let own = path(&outside, "doc.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + rect(&mut s); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + let mut refused = |id: &str, p: Value| confine(roots.as_ref(), || s.execute(id, &p)).unwrap_err().to_string(); + + let e = refused("document.save", json!({})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + let e = refused("document.save", json!({"separateArtboards": true})); + assert!(e.starts_with(&format!("automation path rejected: outside the write root {root}: ")) && !e.contains(&hidden), "{e}"); + let e = refused("file.revert", json!({})); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: doc.vectorcraft")); + // Given by the client: whole. + let e = refused("document.save", json!({"path": own})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: {own}")); + // Without read roots, a write refusal still names the file by its file name. + let write_only = AutomationRoots::new(&[], &[inside.as_path()]).unwrap(); + let e = confine(write_only.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + assert!(s.doc().unwrap().is_dirty(), "nothing was saved"); + // Read with losses from its own file: the refusal to write over it names the file by its file name. + let st = s.doc_mut().unwrap(); + st.imported_from = Some(own.clone()); + st.import_losses = vec!["hidden text".into()]; + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert!(e.starts_with("invalid parameters for `document.save`: doc.vectorcraft is the file this document was read from"), "{e}"); + assert!(!e.contains(&hidden), "{e}"); + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({"path": own}))).unwrap_err().to_string(); + assert!(e.contains(&format!("{own} is the file this document was read from")), "{e}"); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); @@ -569,6 +618,30 @@ fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() { std::fs::remove_dir_all(d).unwrap(); } +#[test] +fn eps_save_with_use_artboards_refuses_to_replace_the_file_read_from_before_writing_any_file() { + let d = dir("eps-artboards-lossy-sibling"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let sibling = r["files"][1].as_str().unwrap().to_string(); + let original = std::fs::read(&sibling).unwrap(); + std::fs::remove_file(&eps_path).unwrap(); + let st = s.doc_mut().unwrap(); + st.imported_from = Some(sibling.clone()); + st.import_losses.push("test import omitted artwork".into()); + let err = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap_err().to_string(); + assert!(err.contains("acknowledgeLoss") && err.contains(&sibling), "{err}"); + assert_eq!(std::fs::read(&sibling).unwrap(), original); + assert!(!std::path::Path::new(&eps_path).exists(), "no file of the set is written when one is refused"); + s.execute("document.save", &json!({"path": eps_path, "options": options, "acknowledgeLoss": true})).unwrap(); + assert!(std::path::Path::new(&eps_path).exists()); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() { let d = dir("eps-artboards"); diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 3171657b32..7e768c5875 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -230,6 +230,42 @@ 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"); } +/// 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. +#[test] +fn a_run_that_does_not_compile_keeps_no_place() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-uncompiled-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + // A kept place counts the names of the files the run read: four places of the failed run's size + // would not fit in the budget. + let mut text = String::from("#targetengine e\n"); + for k in 0..10 { + std::fs::write(base.join(format!("ok{k}.jsxinc")), "var ok = 1;").unwrap(); + text.push_str(&format!("#include \"ok{k}.jsxinc\"\n")); + } + std::fs::write(base.join("bad.jsxinc"), "var = ;").unwrap(); + text.push_str("#include \"bad.jsxinc\""); + let bad = base.join("bad.jsx"); + std::fs::write(&bad, text).unwrap(); + let mut s = Session::new(); + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(3000))); + run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap(); + let mut kinds = vec![]; + for _ in 0..5 { + assert!(s.execute("script.run", &json!({"path": bad.to_string_lossy()})).is_err()); + kinds.push(s.script_report().unwrap()["kind"].clone()); + } + assert!(run(&mut s, "#targetengine e\nc();").is_err()); + let c_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + let _ = std::fs::remove_dir_all(base); + assert!(kinds.iter().all(|k| k == "syntax"), "{kinds:?}"); + assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4)); +} + /// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to /// that run's script, and as `$.evalFile` does in a run given as text. #[test] diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs index b176b4f143..312db3e0fc 100644 --- a/crates/engine/src/tests/script_folder.rs +++ b/crates/engine/src/tests/script_folder.rs @@ -311,7 +311,9 @@ fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { s.attach_scripts_dir(root.clone()); let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err(); - assert!(e.to_string().contains("outside the read root"), "{e}"); + // The Scripts folder is the app's: the error keeps the root and names the folder by its name. + let shown_root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work).unwrap()).display().to_string(); + assert_eq!(e.to_string(), format!("automation path rejected: outside the read root {shown_root}: Scripts")); let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err(); assert!(e.to_string().contains("outside the read root"), "{e}"); // A read root that holds the folder lets both through. diff --git a/crates/engine/src/tests/shaper.rs b/crates/engine/src/tests/shaper.rs index 891d03e7a7..38e61241f0 100644 --- a/crates/engine/src/tests/shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -259,6 +259,93 @@ fn shaper_double_click_selects_an_original_for_construction_editing() { assert_eq!(s.doc().unwrap().selection.objects, vec![g]); } +/// With a Shaper Group or one of its faces selected, a Shaper click on an original shape's outline +/// enters Construction mode with that shape selected, even where the outline lies inside a merged +/// face. A click away from every outline still selects a face, and a click on an outline of a group +/// that is not selected selects the group. +#[test] +fn shaper_click_on_an_original_outline_enters_construction_mode() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let sources = node(&s, g).children().unwrap()[0].id; + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + }; + s.execute("select.none", &json!({})).unwrap(); + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // The right side of the back shape runs through the merged face. + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A click inside the face, away from the outlines, selects the face. + click(&mut s, 75.0, 50.0); + let face = s.doc().unwrap().selection.objects.clone(); + assert_eq!(face.len(), 1); + assert_ne!(face, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // With the face selected, a click on the front shape's outline picks that shape. + click(&mut s, 149.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for tolerance in [json!("3"), json!(-1), json!(1e9)] { + assert!(s.execute("shaper.select", &json!({"point": [101, 50], "tolerance": tolerance})).is_err(), "{tolerance}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + s.execute("shaper.select", &json!({"point": [104, 50], "tolerance": 5})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A wide tolerance (a zoomed-out view) reaches an outline from well outside the group's bounds. + s.execute("shaper.select", &json!({"point": [165, 50], "tolerance": 20})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + // A locked or hidden original shape is not picked by its outline. + for hide in ["object.lock", "object.hide"] { + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute(hide, &json!({})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [149, 50]})).unwrap(); + assert_ne!(s.doc().unwrap().selection.objects, vec![b], "{hide}"); + assert_eq!(s.doc().unwrap().isolation, None, "{hide}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + } +} + +/// A click on a selected Shaper Group's original outline where another Shaper Group lies above it +/// selects the group above, as a click there did before. +#[test] +fn shaper_outline_click_under_another_group_selects_the_group_above() { + let mut s = session(); + rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let c = rectangle(&mut s, 20.0, "#00ff00"); + let d = rectangle(&mut s, 70.0, "#ffff00"); + let upper = NodeId( + s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [lower.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [100, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![upper]); + assert_eq!(s.doc().unwrap().isolation, None); +} + /// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with /// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source /// shape, and the mode lasts through undo and redo of an edit made in it. diff --git a/crates/engine/src/tests/strokegeom.rs b/crates/engine/src/tests/strokegeom.rs index f7500af358..524dabef3e 100644 --- a/crates/engine/src/tests/strokegeom.rs +++ b/crates/engine/src/tests/strokegeom.rs @@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() { assert!(spec.params.contains("arrowAlign")); // …and every arrowhead name `startArrow`/`endArrow` accept. for a in vectorcraft_doc::Arrowhead::ALL { - let name = serde_json::to_value(a).unwrap(); + let name = serde_json::to_value(&a).unwrap(); assert!(spec.params.contains(name.as_str().unwrap()), "{name}"); s.execute("stroke.set", &json!({"endArrow": name})).unwrap(); assert_eq!(stroke(&s, id).end_arrow, Some(a)); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index abb39c0d96..adcc35d352 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -87,6 +87,50 @@ fn set_range_style_splits_runs() { assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err()); } +/// `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle` refuse a size +/// outside 0.1 to 1296 pt with an error naming the range, and change nothing; both ends are taken. +#[test] +fn a_type_size_outside_its_range_is_refused() { + let mut s = session(); + let message = "`size` must be from 0.1 to 1296"; + let state = |s: &Session| (s.doc().unwrap().doc.node_count(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for size in [2835.0, 1296.5, 0.05, 0.0, -1.0] { + let e = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": size})).unwrap_err().to_string(); + assert!(e.contains(message), "{size}: {e}"); + } + assert_eq!(state(&s), before, "no type is made"); + let big = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": 1296})).unwrap(); + let big = NodeId(big["id"].as_u64().unwrap()); + assert_eq!(obj(&s, big).runs[0].style.size, 1296.0); + let small = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "small", "size": 0.1})).unwrap(); + assert_eq!(obj(&s, NodeId(small["id"].as_u64().unwrap())).runs[0].style.size, 0.1); + let path = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let before = state(&s); + let e = s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 2835})).unwrap_err().to_string(); + assert!(e.contains(message), "{e}"); + assert_eq!(state(&s), before); + s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 1296})).unwrap(); + let id = text(&mut s, "Hello"); + let before = state(&s); + for (cmd, params) in [ + ("text.setStyle", json!({"ids": [id.0], "size": 2835})), + ("text.setStyle", json!({"ids": [id.0], "size": "1300 pt"})), + ("text.setStyle", json!({"ids": [id.0], "size": 0.05})), + ("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 2835})), + ("text.setRangeStyle", json!({"id": id.0, "size": 0})), + ] { + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains(message), "{cmd} {params}: {e}"); + } + assert_eq!(state(&s), before, "nothing changes"); + assert_eq!(obj(&s, id).runs[0].style.size, 20.0); + s.execute("text.setStyle", &json!({"ids": [id.0], "size": 1296})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 1296.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "size": 0.1})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 0.1); +} + /// `featureChanges` turns features on and off on top of each run's own and keeps the rest, in /// `text.setStyle` and `text.setRangeStyle`, in one undo step; an unknown tag is an error. #[test] @@ -139,6 +183,54 @@ fn feature_changes_keep_each_runs_other_features() { assert_eq!(features(&s), [vec!["smcp", "numr", "liga", "dnom"], vec!["swsh", "numr", "liga", "dnom"], vec!["-onum", "swsh", "dnom"]]); } +/// A feature with a value (`salt=2`): `features` and `featureChanges` take it, the next value +/// replaces it, `salt=1` is a plain `salt` and `salt=0` and `-salt` are off, a value this build +/// does not know is an error, one undo step each, and the native format keeps the plain string. +#[test] +fn feature_values_are_set_replaced_and_saved() { + let mut s = session(); + let id = text(&mut s, "abcd"); + let features = |s: &Session| obj(s, id).runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::>(); + let range = |s: &mut Session, p: Value| { + let mut p = p; + p["id"] = json!(id.0); + p["start"] = json!(1); + p["end"] = json!(2); + s.execute("text.setRangeStyle", &p).map(|_| ()) + }; + let undo = s.doc().unwrap().history.undo.len(); + range(&mut s, json!({"features": ["salt=2"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(features(&s), [("a".into(), vec![]), ("b".into(), vec!["salt=2".to_string()]), ("cd".into(), vec![])]); + range(&mut s, json!({"features": ["salt=3"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["salt=3"], "the next value replaces it"); + range(&mut s, json!({"featureChanges": ["salt=2", "ss01"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "written in the canonical order"); + range(&mut s, json!({"featureChanges": ["salt=1"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt"], "salt=1 is a plain salt"); + range(&mut s, json!({"featureChanges": ["salt=2"]})).unwrap(); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&saved).unwrap(); + let Some(NodeKind::Text(t)) = back.node(id).map(|n| &n.kind) else { panic!("the text survives") }; + assert_eq!(t.runs[1].style.features, ["ss01", "salt=2"], "the native format keeps the string"); + assert!(String::from_utf8_lossy(&saved).contains("\"salt=2\"")); + range(&mut s, json!({"featureChanges": ["-salt"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "-salt turns it off"); + range(&mut s, json!({"featureChanges": ["salt=2"], "features": null})).unwrap(); + range(&mut s, json!({"featureChanges": ["salt=0"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "salt=0 turns it off"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "undo takes it back"); + let before = features(&s); + for tag in ["salt=", "salt=x", "salt=65536", "-salt=2", "liga=2", "salt=2=3", "zzzz=2"] { + for key in ["features", "featureChanges"] { + let e = range(&mut s, json!({ key: [tag] })).unwrap_err().to_string(); + assert!(e.contains(&format!("`{tag}`")), "{key} {tag}: {e}"); + } + } + assert_eq!(features(&s), before, "the refused commands change nothing"); +} + /// `text.setStyle {stroke, strokeWidth}` sets the character stroke of all the text or of a range, as /// `text.setRangeStyle` does, and a call with `fill` and `stroke` sets both in one undo step. #[test] @@ -476,6 +568,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), + ("text.setFormat", json!({"ids": [id.0], "bunriKinshi": "on"}), "bunriKinshi"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), @@ -3166,6 +3259,41 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() { assert!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap_err().to_string().contains("is not available")); } +/// `glyphAlternate: null` in `text.setRangeStyle` takes a glyph choice away in the same undo step +/// as the other style it sets (a feature: what the Glyphs panel does for a variant a feature +/// draws); anything but null is refused. +#[test] +fn set_range_style_takes_a_glyph_choice_away_with_a_feature() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xay", "size": 20, "font": alternates_family(true)})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap(); + let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::>(); + assert_eq!(gids(&s)[1], u1); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": 5})).unwrap_err().to_string(); + assert!(e.contains("takes only null"), "{e}"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null, "featureChanges": ["ss01"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); + assert_eq!( + gids(&s)[1], + face.substitutions(face.glyph_for('a')).iter().find(|x| &x.feature == b"ss01").map(|x| x.glyphs[0]).unwrap(), + "ss01 draws its glyph" + ); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gids(&s)[1], u1, "undone: the choice again"); + // With nothing else to set it is still a change. + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null})).unwrap(); + assert_eq!(gids(&s)[1], face.glyph_for('a')); +} + /// The glyph already drawn, chosen again, and null for a character drawn with its default glyph /// leave the document and its undo history as they are. An empty run before the character does /// not stand in for the character's own choice. @@ -3558,6 +3686,89 @@ fn an_overflowing_grapheme_keeps_the_styles_of_its_characters() { assert_eq!(r["gid"], json!(g('b')), "the overflowing Ka is shaped apart from the nukta of another size"); } +/// A contextual alternate with no feature must name the fallback face that supplies the glyph, +/// including when the character is outside its frame. Neither another face's glyph nor an +/// out-of-range id becomes a choice. +#[test] +fn a_contextual_fallback_glyph_choice_draws_through_undo_and_reopen() { + use vectorcraft_text::test_fonts::{ + CONTEXTUAL_FALLBACK_CHAR as CH, CONTEXTUAL_FALLBACK_FAMILY, alternates_family, alternates_font, contextual_fallback_font, source_sans_gid, + }; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the primary test font builds")); + db.add_font(contextual_fallback_font().expect("the fallback test font builds")); + let primary = db.face(alternates_family(true), "Regular").expect("the primary face"); + let fallback = db.face(CONTEXTUAL_FALLBACK_FAMILY, "Regular").expect("the supplying face"); + let g = |c| u32::from(source_sans_gid(c).expect("a Source Sans glyph")); + let (default, alternate) = (g('m'), g('d')); + assert_eq!(primary.glyph_for(CH), 0); + assert_eq!(db.face_covering(CH).unwrap().id(), fallback.id(), "only the fixture supplies the private-use character"); + assert!(!fallback.alternates(CH).iter().any(|a| a.gid == alternate), "the alternate belongs to its shaped, not nominal, glyph"); + assert!(!fallback.is_unlisted_alternate(CH, alternate), "the contextual validation path is required"); + let text = format!("a{CH}a"); + let start = 1; + let end = start + CH.len_utf8(); + let drawn = |t: &TextObject| { + let mut open = t.clone(); + open.kind = TextKind::Point; + vectorcraft_text::layout(db, &open).glyphs.into_iter().find(|g| g.byte == start).map(|g| (g.font_id, g.gid)) + }; + let state = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision); + + for overflow in [false, true] { + let mut s = session(); + let mut spec = json!({"x": 0, "y": 0, "text": text, "size": 20, "font": alternates_family(true)}); + if overflow { + spec["area"] = json!({"width": 200, "height": 2}); + } + let id = NodeId(s.execute("text.create", &spec).unwrap()["id"].as_u64().unwrap()); + let before = obj(&s, id); + assert_eq!(drawn(&before), Some((fallback.id(), default))); + if overflow { + assert!(vectorcraft_text::layout(db, &before).glyphs.is_empty(), "the original frame overflows"); + } + let contextual = vectorcraft_text::alternates_in_context(db, &before, start).expect("the panel's contextual alternates"); + assert_eq!((contextual.font_id, contextual.default), (fallback.id(), default)); + assert!(contextual.alternates.iter().any(|a| a.gid == alternate && a.apply.is_none())); + + let unchanged = state(&s); + for gid in [primary.glyph_for('a'), fallback.glyph_count()] { + let err = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": gid})).unwrap_err(); + assert!(err.to_string().contains("is not an alternate"), "{err}"); + assert_eq!(state(&s), unchanged, "a refused choice makes no edit"); + assert_eq!(obj(&s, id).runs, before.runs); + } + let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": alternate})).unwrap(); + assert_eq!(result, json!({"id": id.0, "start": start, "end": end, "gid": alternate})); + assert_eq!(state(&s).0, unchanged.0 + 1, "one undo step"); + let chosen = obj(&s, id); + assert_eq!(chosen.plain_text(), text); + assert_eq!(drawn(&chosen), Some((fallback.id(), alternate))); + let choice = chosen.runs.iter().find_map(|r| r.style.glyph_alternate.as_ref()).expect("the stored choice"); + assert_eq!(choice, &fallback.choice(CH, alternate), "the choice keeps the supplying face's identity"); + + let mut wrong_face = chosen.clone(); + wrong_face.runs.iter_mut().find(|r| r.style.glyph_alternate.is_some()).unwrap().style.glyph_alternate = Some(primary.choice(CH, alternate)); + assert_eq!(drawn(&wrong_face), Some((fallback.id(), default)), "another face's choice is not drawn"); + + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(reopened) = &reopened.node(id).unwrap().kind else { panic!("text missing") }; + assert_eq!(drawn(reopened), Some((fallback.id(), alternate)), "native restore retains the face and glyph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).runs, before.runs, "Undo restores the original runs"); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default))); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), alternate)), "Redo restores the choice"); + + let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": null})).unwrap(); + assert_eq!(result["gid"], json!(default), "clearing reports the fallback's shaped glyph"); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default))); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none())); + } +} + #[test] fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 4b773c889c..08ba426ef1 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() { s.execute("dataset.select", &json!({"name": "B"})).unwrap(); assert_eq!(super::history(&s), (before.0 + 1, 0, true)); } + +/// The document's SVG with the options `svg` → (its text, the export's warnings). +fn svg_export(s: &mut Session, svg: Value) -> (String, Vec) { + let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default()) +} + +/// A new session with `svg` opened as `name` → (the session, the open's warnings). +fn svg_opened(svg: &str, name: &str) -> (Session, Vec) { + let mut o = Session::new(); + let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + (o, warnings) +} + +/// Include Variable Data writes the text variables, the data sets' text values and each text +/// variable's bound type object into the SVG, and opening the SVG reads them back: the type +/// objects are bound again, so a data set changes them. Variables of other kinds are left out with +/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style +/// entities alike, and Load Variable Library reads the same file. +#[test] +fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let u = text(&mut s, "9.99"); + // A fill of its appearance writes the type in a group with the fill's path. + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + let r = rect(&mut s); + for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A & ", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let text_only: Vec = before + .datasets + .iter() + .map(|d| { + let mut d = d.clone(); + d.values.retain(|k, _| k != "Shown"); + d + }) + .collect(); + + for opts in [ + json!({"variableData": true}), + json!({"variableData": true, "objectIds": "minimal", "minify": true}), + json!({"variableData": true, "objectIds": "unique"}), + json!({"variableData": true, "styling": "entities", "encoding": "latin1"}), + ] { + let (svg, warnings) = svg_export(&mut s, opts.clone()); + assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}"); + assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}"); + assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing"); + + let (mut o, warnings) = svg_opened(&svg, "vars.svg"); + assert!(warnings.is_empty(), "{opts}: {warnings:?}"); + let vars = o.doc().unwrap().doc.variables.clone(); + assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::>(), [("Name", "text"), ("Price", "text")]); + assert_eq!(vars.datasets, text_only, "{opts}"); + assert_eq!(vars.active_dataset, None); + let bound = |name: &str| { + let ids = vars.objects_of(name); + assert_eq!(ids.len(), 1, "{opts}: {name}"); + ids[0] + }; + let (name, price) = (bound("Name"), bound("Price")); + assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it"); + let v = o.execute("dataset.select", &json!({"name": "A & "})).unwrap(); + assert_eq!(v["applied"], json!(2), "{opts}: {v}"); + assert_eq!(plain(&o, name), "Bob"); + + let mut lib = session(); + lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap(); + assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}"); + } +} + +/// Without Include Variable Data the SVG holds no variable data, and a document without variables +/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left +/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each +/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a +/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file +/// whose variable data can't be read opens its art with a warning, and so does one whose +/// namespace entities would grow the library past its cap. +#[test] +fn an_svg_without_readable_variable_data_opens_its_art() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let (plain_svg, _) = svg_export(&mut s, json!({})); + assert!(!plain_svg.contains("variableSets")); + let (none, _) = svg_export(&mut s, json!({"variableData": true})); + assert!(!none.contains("variableSets"), "no variables, no variable data"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + let (off, _) = svg_export(&mut s, json!({})); + assert!(!off.contains("variableSets"), "off by default"); + // Outlined type opens as art: the variable is written unbound, and the values lose the + // characters XML can't hold, each with a warning. + s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap(); + let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true})); + assert!(outlined.contains(""; + let art = ""; + let untraited = format!("{head}{art}"); + let refs = "&ns_big;".repeat(40_000); + let big = "x".repeat(250); + let growing = format!( + "]>{head}{art}" + ); + // Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million + // one-character paragraphs, minified) is left out with a warning. + let mut lines = session(); + lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap(); + let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true})); + assert!(!dense.contains("variableSets")); + assert!( + warnings.contains(&json!( + "The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG" + )), + "{warnings:?}" + ); + + // More text variables and data sets than Load Variable Library reads: the first 256 and the + // first 1,000 are written, each with a warning, and the file opens with them. + let mut many = session(); + { + let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables; + vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect(); + vars.datasets = (0..1_100) + .map(|i| vectorcraft_doc::DataSet { + name: format!("D{i}"), + values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(), + }) + .collect(); + } + let (capped, warnings) = svg_export(&mut many, json!({"variableData": true})); + for w in [ + "Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data", + "Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data", + ] { + assert!(warnings.contains(&json!(w)), "{warnings:?}"); + } + let (o, warnings) = svg_opened(&capped, "many.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + let vars = &o.doc().unwrap().doc.variables; + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000)); + assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into())); + + // Text that only looks like variable data, in a comment, a CDATA section, a processing + // instruction or the document type declaration, is skipped for the element itself. + let library = ""; + let decoys = format!( + " -->]>{head} ]]> ?>{library}{art}" + ); + let (o, warnings) = svg_opened(&decoys, "decoys.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1); + + for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] { + let (o, warnings) = svg_opened(&svg, "broken.svg"); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}"); + let d = &o.doc().unwrap().doc; + assert!(d.variables.variables.is_empty()); + assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); + } +} diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index e9aa3c5329..54c653e938 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -526,6 +526,51 @@ fn distort_command_maps_corners() { assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0))); } +/// Free Distort warps the art of a linked opacity mask with the object it masks, and one undo puts +/// both back; an unlinked mask stays where it is. +#[test] +fn distort_command_warps_a_linked_opacity_mask_with_its_object() { + for linked in [true, false] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + if !linked { + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + } + let mask_path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().mask.as_ref().unwrap().art.path_data().cloned(); + let path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().cloned(); + let before = (path(&s), mask_path(&s)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap(); + assert!(path(&s) != before.0); + let want = if linked { path(&s) } else { before.1.clone() }; + assert_eq!(mask_path(&s), want, "linked: {linked}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s), mask_path(&s)), before); + } +} + +/// Free Distort of a group warps the linked opacity mask of a path inside it with the path. +#[test] +fn distort_command_warps_the_linked_mask_of_a_path_in_a_group() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let c = rect(&mut s, 150.0, 0.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let before = s.doc().unwrap().doc.node(a).unwrap().path_data().cloned(); + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + s.execute("object.distort", &json!({"corners": [[10, 0], [190, 0], [200, 100], [0, 100]]})).unwrap(); + let a = s.doc().unwrap().doc.node(a).unwrap(); + assert!(a.path_data().cloned() != before, "the path is warped"); + assert_eq!(a.extras().mask.as_ref().unwrap().art.path_data(), a.path_data(), "and so is its mask"); +} + #[test] fn distort_perspective_midpoint_is_projective() { // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9bc450f937..ad1549140f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1090,22 +1090,51 @@ impl Session { self.tool.resizing() } - /// With Real-time Drawing off, how far a Selection-tool move drag has carried the selection - /// so far: the document moves on release, and the bounding box shows at this offset meanwhile. - pub fn deferred_move(&self) -> Option { - if self.tool.id() != "selection" || !self.tool.transforming() { + /// Does the canvas draw the bounding box around a shape being drawn: the active tool is drawing + /// a new shape whose box it shows once drawn (see `Tool::drawing_boxed`), and General › Show + /// Bounding Box While Moving and Drawing is on? + pub fn draws_new_shape_box(&self) -> bool { + self.prefs.bounding_box_while_moving_and_drawing && self.tool.drawing_boxed() + } + + /// Has the interaction no preview applied to the document: none has run yet, the last one failed, + /// or Real-time Drawing is off and the document is still the interaction's snapshot? + pub fn interaction_unapplied(&self) -> bool { + self.active().is_some_and(|d| d.interaction.as_ref().is_some_and(|it| it.preview.is_none() || std::sync::Arc::ptr_eq(&it.doc, &d.doc))) + } + + /// The bounding box of the selection a move drag is carrying (an Alt-drag copy included), where + /// the drag has carried it: the box of the selection at the drag's start, measured once per + /// drag, offset by the move of the latest preview. With General › Show Bounding Box While + /// Moving and Drawing on, for every tool that moves the selection; off, only for the Selection + /// tool with Real-time Drawing off, where the document moves on release. `None` for any other + /// drag, and before a preview has moved the selection. + pub fn move_drag_box(&self) -> Option { + if !self.tool.moving() { + return None; + } + let st = self.active()?; + let it = st.interaction.as_ref()?; + let deferred = std::sync::Arc::ptr_eq(&it.doc, &st.doc); + if !self.prefs.bounding_box_while_moving_and_drawing && !(self.tool.id() == "selection" && deferred) { return None; } - let it = self.active()?.interaction.as_ref()?; if !matches!(it.label.as_str(), "Move" | "Copy") { return None; } - let (cmd, p) = self.unapplied_interaction_preview()?; + let (cmd, p) = it.preview.as_ref()?; if cmd != "object.transform" || p.get("objects").and_then(Value::as_bool) == Some(false) { return None; } - let [a, b, c, d, e, f] = crate::cmd::matrix_param(&p, "matrix")?.as_coeffs(); - (a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0).then(|| vectorcraft_geom::Vec2::new(e, f)) + let [a, b, c, d, e, f] = crate::cmd::matrix_param(p, "matrix")?.as_coeffs(); + if !(a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0) { + return None; + } + let measure = self.bounds_measure(); + let start = + (*it.move_box.get_or_init(|| vectorcraft_render::effects::preview::oriented_bounds_with(&it.doc, &it.selection.objects, measure)))?; + let moved = start.to_local(Point::new(e, f)).to_vec2(); + Some(vectorcraft_doc::OrientedBox { rect: start.rect + moved, ..start }) } /// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)? @@ -1142,8 +1171,11 @@ impl Session { let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position }); v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place)); } - // Global Edit: the objects edited with the selected one are highlighted. - for (path, color) in crate::cmd::globaledit::highlights(self) { + // Global Edit: the objects edited with the selected one are highlighted; before it starts, + // with the Selection tool, the objects it would take. + let ge_on = self.active().is_some_and(|d| d.global_edit.is_some()); + let shown = if ge_on || self.tool.id() == "selection" { crate::cmd::globaledit::highlights(self) } else { vec![] }; + for (path, color) in shown { v.push(Overlay::Path { path, color, width: 1.0, dashed: false }); } if let Some((cmd, p)) = deferred_preview { @@ -1435,23 +1467,30 @@ impl Session { } /// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the - /// pending preview of a handle resize leaves it, measured once per preview step; `None` when no - /// such preview is pending, or when the preview fails. - pub fn deferred_transform_box(&mut self) -> Option { - if !self.tool.resizing() { + /// pending preview of a handle resize or of a shape being drawn leaves it, measured once per + /// preview step, with the layer color of the first object it measures; `None` when no such + /// preview is pending, or when the preview fails. + pub fn deferred_transform_box(&mut self) -> Option<(vectorcraft_doc::OrientedBox, [u8; 3])> { + if !self.measures_deferred_box() { return None; } let (cmd, p) = self.unapplied_interaction_preview()?; self.deferred_feedback(&cmd, &p)?.transform_box } + /// Does the canvas draw the bounding box of what the pending preview leaves: during a handle + /// resize, or while a shape is drawn by a tool that shows its box ([`Session::draws_new_shape_box`])? + fn measures_deferred_box(&self) -> bool { + self.tool.resizing() || self.draws_new_shape_box() + } + fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default() } /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take - /// outlines from the objects it touched (and, during a handle resize, their transform box), then - /// put the document back at the interaction snapshot. + /// outlines from the objects it touched (and, during a handle resize or while a shape is drawn, + /// their transform box), then put the document back at the interaction snapshot. fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> { if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) { let feedback = self.measure_deferred_preview(cmd, p)?; @@ -1472,8 +1511,12 @@ impl Session { }; let (overlays, transform_box) = if self.preview(cmd, p).is_ok() { let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default(); - let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default(); - (overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None }) + let (objects, color) = self + .active() + .map(|st| (st.selection.objects.clone(), st.selection.objects.first().map(|id| st.doc.layer_color(*id)))) + .unwrap_or_default(); + let transform_box = if self.measures_deferred_box() { self.transform_box(&objects).zip(color) } else { None }; + (overlays, transform_box) } else { (vec![], None) }; @@ -1497,7 +1540,7 @@ pub(crate) struct DeferredFeedback { cmd: String, params: Value, overlays: Vec, - transform_box: Option, + transform_box: Option<(vectorcraft_doc::OrientedBox, [u8; 3])>, } /// The most points the outlines of [`outlines_for_selection`] are drawn for, the same number as the diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index 0cc0edc4d4..c3d6df771b 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -57,6 +57,24 @@ fn legacy_profile_name_resolves_in_commands_and_files() { cms::set_active(&before).unwrap(); } +/// Assigning a profile other than the working space replaces the active color settings and raises +/// the other open documents' revisions. +#[test] +fn assigning_another_profile_replaces_the_active_color_settings() { + let _g = settings(); + let mut s = session(); + exec(&mut s, "file.new", json!({"width": 100, "height": 100})); + let before = cms::active_settings(); + assert_ne!(before.cmyk, cms::DEVICE_CMYK); + let active = cms::active(); + let other = s.documents()[0].revision; + exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK})); + assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK); + assert!(!std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were not replaced"); + assert!(s.documents()[0].revision > other, "the other document's revision did not change"); + cms::set_active(&before).unwrap(); +} + /// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray /// square on the Black plate through the new working CMYK profile, and the four process plates /// through a proof profile loaded again under the same name, as a new renderer draws them. @@ -428,6 +446,60 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() { assert!(redrawn, "the canvas redraws"); } +/// Loading a new file of the CMYK profile View → Proof Setup targets, or removing that profile, +/// gives the documents new revisions, as a working space's new file does; loading or removing +/// another profile does not. +#[test] +fn loading_or_removing_the_proof_profile_redraws() { + let _g = settings(); + // 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: revision, "the proof profile's file"); + exec(&mut s, "color.removeProfile", json!({"name": other})); + assert_eq!(s.doc().unwrap().revision, reloaded, "another profile removed"); + exec(&mut s, "color.removeProfile", json!({"name": proofed})); + assert!(s.doc().unwrap().revision > reloaded, "the proof profile removed"); +} + +/// Sets the default proof view and unregisters the profiles when dropped, also after a failed +/// assertion. +struct ProofReset([&'static str; 2]); + +impl Drop for ProofReset { + fn drop(&mut self) { + vectorcraft_render::proof::set_view(Default::default()); + for name in self.0 { + cms::unregister_icc(name); + } + } +} + /// 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}")); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index b41eb112f4..6577754de0 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -9,11 +9,11 @@ use vectorcraft_engine::{Session, command_specs}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::Fixture; use vectorcraft_testkit::invariants::{check_all, check_session, doc_json}; -use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value}; +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 a -/// few need their file and folder params redirected into a temp dir (`safe_value`, which -/// `junk_params` applies). +/// 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() } @@ -190,6 +190,33 @@ fn heavy(id: &str) -> bool { id.starts_with("document.serialize") || id.starts_with("document.export") || id == "document.open" } +/// Is `p` a params object with `k` as its only key? +fn sends_alone(p: &Value, k: &str) -> bool { + p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key(k)) +} + +/// The junk params the sweeps send `c`: all of them, or for a [`heavy`] command the first 12 and, +/// for each key the command catalog names as a file or folder that no case among those sends alone +/// ([`sends_alone`]), the first case that sends a string under it alone (or any value when none +/// does). +fn junk_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { + let mut cases = junk_params_for_command(c, path); + if heavy(c.id) { + let rest = cases.split_off(cases.len().min(12)); + for (k, _) in vectorcraft_engine::cmd::location_keys(c) { + if cases.iter().any(|p| sends_alone(p, k)) { + continue; + } + let alone = || rest.iter().filter(|p| sends_alone(p, k)); + let found = alone().find(|p| p.get(k).is_some_and(Value::is_string)).or_else(|| alone().next()); + if let Some(p) = found { + cases.push(p.clone()); + } + } + } + cases +} + #[test] fn every_command_with_junk_params() { let path = safe_path(); @@ -197,10 +224,7 @@ fn every_command_with_junk_params() { let mut calls = 0usize; for fx in Fixture::ALL { for c in command_specs() { - let mut cases = junk_params(c.params, &path); - if heavy(c.id) { - cases.truncate(12); - } + let cases = junk_cases(c, &path); for p in &cases { calls += 1; if let Some(f) = probe(fx, c.id, p) { @@ -234,13 +258,10 @@ fn tool_edit_state(s: &Session) -> ToolEditState { /// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and /// off, with a string under a file or folder key replaced by a location inside `path`'s folder. fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { - let mut cases = junk_params(c.params, path); - if heavy(c.id) { - cases.truncate(12); - } - for k in param_keys(c.params) { + let mut cases = junk_cases(c, path); + for k in command_param_keys(c) { for v in [json!(true), json!(false), json!(1), json!("on")] { - cases.push(json!({ k.as_str(): safe_value(&k, &v, path) })); + cases.push(json!({ k.as_str(): safe_value_for_command(c, &k, &v, path) })); } } // Find Objects selects only with a filter, which one key at a time does not give it. @@ -250,35 +271,109 @@ fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec) { + match v { + Value::String(s) if named && !std::path::Path::new(s).starts_with(dir) => { + out.insert(format!("{id} {at}: {v}")); + } + Value::Array(a) => { + for (i, x) in a.iter().enumerate() { + outside_locations(id, &format!("{at}[{i}]"), x, named, dir, out); + } + } + Value::Object(o) => { + for (k, x) in o { + outside_locations(id, &format!("{at}.{k}"), x, names_location(None, k), dir, out); + } + } + _ => {} + } +} + +/// Does `key` name a file or folder, in command `spec` when given: a key in +/// `vectorcraft_engine::cmd::key_location`'s list that `vectorcraft_engine::cmd::location_in` keeps +/// for `spec`. +fn names_location(spec: Option<&vectorcraft_engine::CommandSpec>, key: &str) -> bool { + match spec { + Some(spec) => vectorcraft_engine::cmd::location_in(spec, key).is_some(), + None => vectorcraft_engine::cmd::key_location(key).is_some(), + } +} + +/// Every string the sweeps send under a key that names a file or folder, in the command catalog +/// (`vectorcraft_engine::cmd::location_keys`, so a command added later is checked too) or inside +/// an object or an array at any depth, is inside the sweep's temp folder, so no run reads or +/// writes in the working directory. `every_command_with_junk_params` sends every such key of every +/// 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 keys = ["path", "diffPath", "folder", "destinationFolder"]; - let mut seen = std::collections::BTreeSet::new(); + let mut unsent = vec![]; let mut outside = std::collections::BTreeSet::new(); for c in command_specs() { - let mut cases = junk_params(c.params, &path); + let keys = vectorcraft_engine::cmd::location_keys(c); + let run = junk_cases(c, &path); + for (k, _) in &keys { + if !run.iter().any(|p| sends_alone(p, k)) { + unsent.push(format!("{} {k}", c.id)); + } + } + let mut cases = junk_params_for_command(c, &path); cases.extend(tool_edit_cases(c, &path)); + cases.extend(serializable_cases(c, &path)); for p in &cases { let Some(o) = p.as_object() else { continue }; - for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) { - seen.insert(k.clone()); - if let Some(s) = v.as_str() - && !std::path::Path::new(s).starts_with(&dir) - { - outside.insert(format!("{} {{\"{k}\": {v}}}", c.id)); - } + for (k, v) in o { + outside_locations(c.id, k, v, names_location(Some(c), k), &dir, &mut outside); } } } - assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}"); + assert!(unsent.is_empty(), "every_command_with_junk_params sends no case for {unsent:?}"); let outside: Vec = outside.into_iter().collect(); assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n")); } +/// The cases sweep keys the command catalog names as files and folders: `document.open`'s +/// `{path | name …}`, `addon.document.saveTimestampedCopy`'s `{folder? (…)}`, `document.compare`'s +/// `diffPath`, `file.saveAs`'s `path` after `{…as document.save}` and `links.relink`'s +/// `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 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 [ + ("document.open", "path"), + ("addon.document.saveTimestampedCopy", "folder"), + ("document.compare", "diffPath"), + ("text.addFontFiles", "files"), + ("file.saveAs", "path"), + ] { + let cases = junk_params_for_command(spec(id), &path); + let sent: Vec<&Value> = cases.iter().filter_map(|p| p.get(key)).collect(); + assert!(sent.iter().any(|v| inside(v) || v.as_array().is_some_and(|a| a.iter().any(inside))), "{id} sends no `{key}` in the temp folder"); + } + for (id, key) in + [("document.open", "path"), ("document.open", "name"), ("addon.document.saveTimestampedCopy", "folder"), ("links.relink", "folder")] + { + assert!(param_keys(spec(id).params).iter().any(|k| k == key), "{id} has no `{key}` key"); + } + let folder = junk_params_for_command(spec("addon.document.saveTimestampedCopy"), &path); + assert!(folder.iter().filter_map(|p| p.get("folder")?.as_str()).all(|s| s != path && std::path::Path::new(s).starts_with(&dir))); + let relink = junk_params_for_command(spec("links.relink"), &path); + let nested = relink.iter().filter_map(|p| p.get("paths")?.as_array()).flatten().filter_map(|e| e.get("path")).filter(|v| v.is_string()); + assert!(nested.clone().count() > 0 && nested.clone().all(inside), "links.relink sends no `paths[].path` in the temp folder"); + // A key that names part of the document in a command keeps its junk there. + let on_path = junk_params_for_command(spec("text.createInPath"), &path); + assert!(on_path.iter().any(|p| p.get("path") == Some(&json!("junk")))); +} + /// Every command that runs at once while the active tool computes an edit on another thread (a /// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the /// documents' revisions and selections, the active document and the current paint as they were, @@ -798,6 +893,12 @@ fn structured_junk() { ("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})), ("addon.pen.nextSegment", json!({"curved": "yes"})), ("addon.pen.closeJoin", json!({"ids": [u64::MAX]})), + // Cutting Tools add-on. + ("addon.cutting.weed", json!({"margin": 1e5, "count": 1000, "gap": 1e5, "direction": "both"})), + ("addon.cutting.weed", json!({"margin": -1, "count": 1.5, "spacing": 0, "gap": f64::MAX, "weight": 1e308})), + ("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})), + ("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})), + ("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})), // Type Tools add-on. ("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})), ("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})), @@ -1007,28 +1108,36 @@ fn polygon_properties_junk() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// The params `fuzzed_calls_keep_documents_serializable` sends `c`: each key with each of the +/// first junk values, a string under a file or folder key replaced by a location inside `path`'s +/// folder. +fn serializable_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { + if heavy(c.id) || c.id.starts_with("file.") { + return vec![]; + } + let mut cases = vec![]; + for v in junk_values().iter().take(8) { + for k in command_param_keys(c) { + cases.push(json!({ k.as_str(): safe_value_for_command(c, &k, v, path) })); + } + } + cases +} + /// After a successful fuzzed call, the document still round-trips and exports. #[test] fn fuzzed_calls_keep_documents_serializable() { let path = safe_path(); let mut failures = vec![]; for c in command_specs() { - if heavy(c.id) || c.id.starts_with("file.") { - continue; - } - for v in junk_values().iter().take(8) { - for k in param_keys(c.params) { - let k = if k == "path" { continue } else { k }; - let mut s = Fixture::Multi.session(); - let p = json!({ k.clone(): v }); - let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false); - if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) { - failures.push(format!("{} {p}: {e}", c.id)); - } + for p in serializable_cases(c, &path) { + let mut s = Fixture::Multi.session(); + let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false); + if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) { + failures.push(format!("{} {p}: {e}", c.id)); } } } - let _ = path; failures.sort(); failures.dedup(); assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::>().join("\n")); @@ -1091,7 +1200,7 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() { for fx in Fixture::ALL { for id in ["file.recovery.save", "file.recovery.list", "file.recovery.restore", "file.recovery.discard"] { let spec = command_specs().iter().find(|c| c.id == id).unwrap(); - for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) { let mut s = fx.session(); s.recovery.set_store(store.clone()); let r = catch_quiet(|| no_panic(s.execute(id, &p))); @@ -1120,7 +1229,7 @@ fn envelope_commands_with_junk_params_on_envelopes() { command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect(); for id in ids { let spec = command_specs().iter().find(|c| c.id == id).unwrap(); - for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) { let mut s = Fixture::Multi.session(); if s.execute(make, mp).is_err() { failures.push(format!("{make} failed on the Multi fixture")); @@ -1217,7 +1326,7 @@ fn text_commands_with_junk_params_on_inline_graphics() { } for id in ["text.insertInline", "text.editRange", "text.getRange", "text.setRangeStyle", "type.insert"] { let spec = command_specs().iter().find(|c| c.id == id).unwrap(); - for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) { run(id, &p); } } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ac98db8e9c..4688845de4 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2958,6 +2958,13 @@ fn the_hpgl_sample_opens() { assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings); } +/// A wedge drawn with the adaptive line type `LT-1` and a near-zero radius (a case the fuzz test +/// found) opens and places without a panic. +#[test] +fn an_adaptive_line_type_wedge_of_almost_no_radius_opens() { + survive_metafile("HP-GL tiny adaptive wedge", "x.plt", b"IN;SP1;LT-1;EW0.000001,0,0;").unwrap(); +} + /// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers. fn arb_hpgl_instruction() -> impl Strategy { let op = prop::sample::select(vec![ @@ -4330,6 +4337,17 @@ proptest! { survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` whose brushes, as the writer writes them, are damaged: the brush art with its frame, + /// the brush manager's order, the brushes' parameters, a brushed path's style and its object, cut + /// short or edited. + #[test] + fn ai_written_brushes_never_panic(cut in 0usize..BRUSHES_FUZZ_CHARS, edits in prop::collection::vec((0usize..BRUSHES_FUZZ_CHARS, prop::sample::select(vec!['0', '1', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'n', 'X', 'W'])), 0..12)) { + let text = mutate_text(brushes_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai brushes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("mutated .ai brushes, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } } /// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`]. @@ -4442,6 +4460,72 @@ fn the_symbol_library_fuzz_data_opens_with_its_symbols() { assert_eq!(names, ["S2", "S1"], "{warnings:?}"); } +/// The range of [`ai_written_brushes_never_panic`]'s cuts and edits: past the end of [`brushes_text`]. +const BRUSHES_FUZZ_CHARS: usize = 48_000; + +/// The editing data VectorCraft writes for a document whose brushes are a Calligraphic brush, a +/// Scatter brush in Tints and an Art brush in Tints (each art a wide block over a narrow one), with a +/// path drawn with the Calligraphic brush and one with the Art brush. +fn brushes_text() -> &'static str { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + use std::sync::Arc; + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Appearance, Node, NodeId}; + use vectorcraft_geom::{Rect, shapes}; + let block = |id: u64, r: Rect| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0))) + }; + let art = || Node::group(NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]); + let nib = Calligraphic { + angle: 30.0, + roundness: 40.0, + size: 6.0, + variation: [20.0, 10.0, 2.0], + modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]), + }; + let lib = [ + Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(nib) }, + Brush { name: "Spray".into(), kind: BrushKind::Scatter(Scatter { art: art(), colorization: Colorization::Tints, ..Scatter::default() }) }, + Brush { name: "Ribbon".into(), kind: BrushKind::Art(ArtBrush { art: art(), colorization: Colorization::Tints, ..ArtBrush::default() }) }, + ]; + let mut doc = Document::new(200.0, 100.0); + vectorcraft_brush::store(&mut doc, &lib); + let layer = doc.layers[0].id; + for (brush, y) in [("Nib", 30.0), ("Ribbon", 70.0)] { + let mut line = Node::path( + doc.alloc_id(), + shapes::line((10.0, y).into(), (190.0, y).into()), + Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some(brush.into()); + doc.insert(Some(layer), usize::MAX, line).unwrap(); + } + let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); + String::from_utf8(out.program).unwrap() + }) +} + +/// The data [`ai_written_brushes_never_panic`] damages, as it is, opens with its brushes and its +/// brushed paths live, and the property's cuts and edits reach all of it. +#[test] +fn the_written_brushes_fuzz_data_opens_live() { + let text = brushes_text(); + assert!(text.chars().count() < BRUSHES_FUZZ_CHARS, "{}", text.chars().count()); + for mark in ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "/GObjRef :", "%AI5_BeginLayer", "\n1 (Anon"] { + assert!(text.replace("\r\n", "\n").contains(mark), "{mark}"); + } + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); + let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let names: Vec = vectorcraft_brush::library(&d).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Nib", "Spray", "Ribbon"]); + let art = d.layers[0].children().unwrap(); + let brushes: Vec> = art.iter().map(|n| n.appearance.stroke().and_then(|s| s.brush.as_deref())).collect(); + assert_eq!(brushes, [Some("Nib"), Some("Ribbon")]); +} + /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the /// symbol list and a layer of instances, as the format writes them. fn symbols_text() -> String { diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3ca8a66b5f..cf068da1ef 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -133,6 +133,16 @@ //! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that //! fills by the even-odd rule is written as its drawn look. A style is written only when the //! reader reads it back as the object's appearance (or the graphic style's). +//! - Brushes, as the reader reads them (`brush.rs`, `style.rs`): the Brushes panel's brushes as +//! plug-in objects in a non-printing block after the symbols (`%AI8_BeginPluginObject`, the +//! plug-in's name, the brush's name, its parameters in strings that `-` continues and `.` ends), +//! after the brush manager's order of them; the art of Scatter and Art brushes in a non-printing +//! block before the symbols (`%AI8_BeginBrushPattern`, its name, a group of an unpainted frame at +//! the art's bounds and the art, or the art alone when it has no width or height), when the +//! document has brushes of its own or an object is written with one. +//! A path or compound path with a brush the reader reads live is written with an art style whose +//! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads +//! it back with its settings. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the @@ -162,15 +172,16 @@ //! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects //! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice //! guides, export types and dynamic type left out, live objects (placed documents included) as their art, -//! strokes a plain line can't draw +//! strokes a plain line can't draw and the object's art style doesn't hold //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on //! layers and opacity masks nested too deep, and, on objects written without an art style, live //! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to -//! graphic styles on objects not written with them, and every art style on a large canvas, are left -//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), -//! counted as the reader counts them, is left out. +//! graphic styles on objects not written with them, brushes the reader wouldn't read back, and every +//! art style and brush on a large canvas, are left out; art past +//! [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), counted as the +//! reader counts them, is left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -288,6 +299,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re let mut w = Writer::new(doc, to_art, origin); // On a large canvas the layers' symbol instances are written as their art. w.symbols_live = scale == 1.0; + w.brushes_live = scale == 1.0; w.symbol_doc = symbol_doc; w.max_objects = max_objects; w.reserve_swatch_gradients(); @@ -305,6 +317,8 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re } // The symbols no instance uses too; their art adds definitions. w.symbols(); + // The brushes, once the layers have said whether an object is written with one. + let (brush_art, brush_objects) = w.brush_blocks(); // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); @@ -323,7 +337,9 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re let mut s = header.clone(); s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n"); w.definitions(&mut s); + s.push_str(&brush_art); w.symbol_definitions(&mut s); + s.push_str(&brush_objects); w.art_styles(&mut s); s.push_str(&palette); w.art_style_list(&mut s); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 1ab878fc62..ccf66c9b21 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -294,6 +294,134 @@ fn writing_again_gives_the_same_bytes() { assert!(first.warnings.is_empty(), "{:?}", first.warnings); } +/// The CMYK equivalent written beside RGB or Lab values is the one of the values as written (to four +/// decimals, Lab clamped to the range a reader reads), so the reopened file is written with the same +/// numbers: a new document's swatches, and colors of many decimals in fills, gradient stops, global +/// process swatches (RGB and Lab), an RGB spot swatch (in a fill and in a stop), Lab spot swatches +/// and a Lab spot swatch out of range. +#[test] +fn cmyk_equivalents_are_written_again_unchanged() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let rgb = |i: u16| Color::rgb(v(i, 0.137_911_3), v(i, 0.271_828_2), v(i, 0.314_159_3)); + let lab = |i: u16| Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0); + let glaze = rgb(102); + let swatch = |name: String, c: Color, spot: bool| Swatch { name, paint: solid(c), global: true, spot }; + let mut named: Vec<(String, Color, bool)> = + vec![("Glaze".into(), glaze, true), ("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65), true)]; + for i in 0..8u16 { + named.push((format!("Leaf {i}"), rgb(i + 110), false)); + named.push((format!("Fern {i}"), lab(i + 120), false)); + named.push((format!("Moss {i}"), lab(i + 130), true)); + } + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.extend(named.iter().map(|(name, c, spot)| swatch(name.clone(), *c, *spot))); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for i in 0..24u16 { + let x = f64::from(i % 8) * 30.0 + 10.0; + let y = f64::from(i / 8) * 30.0 + 10.0; + let n = square(&mut doc, Rect::new(x, y, x + 20.0, y + 20.0), rgb(i)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let mut stops: Vec = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect(); + let out = named[1].1; + stops.push(GradientStop { swatch: Some("Out".into()), tint: 0.5, ..GradientStop::new(0.9, out.tinted(0.5)) }); + stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }); + let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)]; + fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 }))); + for (i, fill) in fills.into_iter().enumerate() { + let x = (i % 9) as f64 * 30.0 + 10.0; + let y = (i / 9) as f64 * 30.0 + 110.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let first = program(&doc); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::>(), &first.lines().map(String::from).collect::>()); +} + +/// With a working RGB space other than sRGB, an RGB fill, an RGB gradient stop and an RGB spot +/// color in a stop are written with their working RGB values, the values the reader reads back, so +/// the reopened file is written with the same RGB values and CMYK equivalents. +#[test] +fn rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space() { + const CHILD: &str = "VECTORCRAFT_TEST_AI_EDITING_WIDE_GAMUT"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: exercise this profile in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "ai_editing::tests::rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space", "--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 color profile test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms; + cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); + assert!(!cms::active().rgb_is_srgb()); + let mut doc = Document::new(300.0, 200.0); + let glaze = Color::rgb(0.812_345_6, 0.234_567_8, 0.456_789_1); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Glaze".into(), paint: solid(glaze), global: true, spot: true }); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + let n = square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.4, 0.2)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.7, 0.3)), + GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let n = painted(&mut doc, shapes::rectangle(Rect::new(40.0, 10.0, 60.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let first = program(&doc); + assert!(first.contains("0.8 0.4 0.2 Xa"), "the fill's working RGB values are written"); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::>(), &first.lines().map(String::from).collect::>()); +} + +/// A gradient stop of a Lab spot color writes the RGB values of the Lab values as written (to four +/// decimals, clamped to the range a reader reads), the same Lab values its CMYK equivalent and the +/// palette's definition come from: for Lab values of many decimals and for Lab values out of range. +#[test] +fn a_lab_spot_stop_writes_rgb_values_of_its_lab_values_as_written() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let mut spots: Vec<(String, Color)> = vec![("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65))]; + spots.push(("Rim".into(), Color::lab(55.555_55, 131.234_56, -12.345_67))); + for i in 0..8u16 { + spots + .push((format!("Moss {i}"), Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0))); + } + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.extend(spots.iter().map(|(name, c)| Swatch { name: name.clone(), paint: solid(*c), global: true, spot: true })); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for (i, (name, c)) in spots.iter().enumerate() { + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.2, 0.3)), + GradientStop { swatch: Some(name.clone()), tint: 0.5, ..GradientStop::new(1.0, c.tinted(0.5)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let x = i as f64 * 25.0 + 10.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let program = program(&doc); + for (name, c) in &spots { + let mut want = String::new(); + crate::ps::push_nums(&mut want, &crate::ai_ops::as_written(*c).to_rgb().map(|v| f64::from(crate::ai_ops::unit(v)))); + want.push_str(&format!("({name}) 0.5 1 4 ")); + assert!(program.lines().any(|l| l.contains(&want)), "no stop of {name} writes {want:?}"); + } +} + #[test] fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { let doc = sample(); @@ -747,6 +875,7 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0)))); } +/// A brushed stroke the reader reads as its drawn look (a Scatter brush's) is written as its brush's art. #[test] fn brushed_strokes_are_written_as_their_brush_art() { let mut doc = Document::new(200.0, 100.0); @@ -756,11 +885,11 @@ fn brushed_strokes_are_written_as_their_brush_art() { shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0), ); - line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + line.appearance.stroke_mut().unwrap().brush = Some("Dots".into()); doc.insert(Some(layer), usize::MAX, line).unwrap(); let pieces = vectorcraft_brush::stroke_pieces( &doc, - &vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(), + &vectorcraft_brush::find(&doc, "Dots").unwrap(), &shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(), doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(), ); @@ -895,6 +1024,7 @@ fn counted() -> Vec<(&'static str, Document)> { ("styles", styles_document()), ("painted clips", painted_clips()), ("blend", blend_doc()), + ("brushes", brushes_document()), ] } @@ -3813,6 +3943,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { } } +/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is +/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported. +#[test] +fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [ + ("Wait——now", true, true), + ("Wait——now", false, false), + ("Wait—now", true, false), + ("Wait……now", true, true), + ("Wait‥‥now", true, true), + ("あ〳〵い", true, true), + ("あ〴〵い", true, true), + ("あ〵〳い", true, false), + ] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.bunri_kinshi = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).bunri_kinshi), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); @@ -5870,25 +6036,6 @@ fn outlines(doc: &Document) -> Vec { out } -/// Fails with the lines where `got` and `want` differ in more than their numbers' last written -/// decimal (a color read back from four decimals gives its other model's values again from them). -fn near_lines(got: &str, want: &str) { - let near = |a: &str, b: &str| { - let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect()); - a.len() == b.len() - && a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::(), y.trim_start_matches("%_").parse::()) { - (Ok(x), Ok(y)) => (x - y).abs() <= 2e-3, - _ => x == y, - }) - }; - let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect()); - let differ: Vec = (0..g.len().max(w.len())) - .filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b))) - .map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i))) - .collect(); - assert!(differ.is_empty(), "{}", differ.join("\n")); -} - #[test] fn objects_with_live_effects_and_graphic_styles_are_read_back() { let doc = styles_document(); @@ -6210,25 +6357,7 @@ fn art_styles_write_the_same_bytes_again() { assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); // What is read back is written as it was. let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; - near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); -} - -/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of -/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and -/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this -/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can -/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float -/// bits, as it does between the first save and the save of the reopened file, and the platform's -/// float math decides which. The RGB values stay compared. -fn rgb_without_cmyk(program: &str) -> String { - let blank = |line: &str| { - let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); - let t: Vec<&str> = rest.split(' ').collect(); - let color = t.len() == 8 && matches!(t[7], "Xa" | "XA"); - let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs"); - if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() } - }; - program.lines().map(blank).collect::>().join("\n") + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&first.program).lines().map(String::from).collect::>()); } /// `n` on the first layer of `doc`. @@ -6629,6 +6758,70 @@ fn every_effect_setting_written_on_an_object_is_read_back() { assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); } +/// A drawn look's strokes warn only where its art style leaves them out: a path with a drop shadow and +/// a stroke with a width profile is written with a style that holds the profile, and doesn't warn; +/// a group with a drop shadow whose member has a stroke inside the path still warns. +#[test] +fn a_drawn_looks_strokes_warn_only_where_its_style_leaves_them_out() { + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let (n, warnings, p) = read_one(|doc| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }); + assert!(p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile)); + let (_, warnings, p) = read_one(|doc| { + let mut n = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), Appearance::basic(Paint::None, black_fill(), 2.0)); + n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside; + let mut g = group(doc, vec![n], false); + g.appearance.effects = vec![drop_shadow()]; + g + }); + assert!(p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); +} + +/// A path linked to a graphic style with a drop shadow and a stroke with a width profile is written +/// with that style, which holds the profile, and doesn't warn; it reads back linked, with its +/// profile. A linked path whose fill is half opaque in Screen mode isn't written with its style, which +/// the reader reads on no object, and warns that the fill's transparency is left out. +#[test] +fn a_linked_paths_drawn_look_warns_only_for_what_its_style_leaves_out() { + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let linked = |doc: &mut Document, fill: FillLayer| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { + items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], + effects: vec![drop_shadow()], + contents_index: None, + }; + let mut g = vectorcraft_doc::GraphicStyle::new("Linked", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(7); + n + }; + let (n, warnings, p) = read_one(|doc| linked(doc, FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))))); + assert!(p.contains("1 (Linked) XW"), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w == writer::STROKES), "{warnings:?}"); + assert!(n.extras().graphic_style.is_some(), "{n:?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile.clone())); + let (_, warnings, p) = read_one(|doc| { + let mut fill = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))); + fill.opacity = 0.5; + fill.blend = BlendMode::Screen; + linked(doc, fill) + }); + assert!(!p.contains("1 (Linked) XW"), "{p}"); + assert!(warnings.iter().any(|w| w == writer::ITEM_TRANSPARENCY), "{warnings:?}"); +} + /// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever /// the writer's conversions to the file's units. #[test] @@ -6911,6 +7104,28 @@ fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() assert!(n.extras().mask.is_some(), "{n:?}"); } +/// An object linked to a graphic style that doesn't fit within the object limit is left out with +/// the limit's warning and the warning about links left out. +#[test] +fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let mut g = vectorcraft_doc::GraphicStyle::new("Shadowed", ap.clone()); + g.id = 9; + doc.graphic_styles.push(g); + // Room for everything but the object. + let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(9); + put(&mut doc, n); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::STYLE_LINKS), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), room).unwrap(); + for w in [writer::OBJECT_LIMIT, writer::STYLE_LINKS] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } +} + /// Twist and Transform on an object that isn't linked to a graphic style are not written as a style /// of its own: the object reads back without live effects. #[test] @@ -7258,6 +7473,772 @@ fn a_large_canvas_writes_every_object_as_its_look() { assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}"); } +// ---------- brushes ---------- + +/// Brush art: a wide block with a narrow one below it (y down), filled black. +fn brush_art_node() -> Node { + let block = + |id: u64, r: Rect| Arc::new(Node::path(vectorcraft_doc::NodeId(id), shapes::rectangle(r), Appearance::basic(black_fill(), Paint::None, 0.0))); + Node::group(vectorcraft_doc::NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]) +} + +/// A Calligraphic brush varying with the barrel's rotation and the pen's pressure, a Scatter brush in +/// Tints, an Art brush in Tints and one that keeps its art's colors. +fn brush_library() -> Vec { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + let art = |colorization| BrushKind::Art(ArtBrush { art: brush_art_node(), colorization, ..ArtBrush::default() }); + vec![ + Brush { + name: "Nib".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 30.0, + roundness: 40.0, + size: 6.0, + variation: [20.0, 10.0, 2.0], + modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]), + }), + }, + Brush { + name: "Spray".into(), + kind: BrushKind::Scatter(Scatter { + art: brush_art_node(), + size: (50.0, 150.0), + spacing: (120.0, 200.0), + scatter: (-25.0, 25.0), + rotation: (-45.0, 30.0), + rotation_relative_to_path: true, + colorization: Colorization::Tints, + modes: Some([Variation::Random, Variation::Fixed, Variation::Pressure, Variation::Random]), + }), + }, + Brush { name: "Ribbon".into(), kind: art(Colorization::Tints) }, + Brush { name: "Plain ribbon".into(), kind: art(Colorization::None) }, + ] +} + +/// A path stroked red at `width` with the brush `brush`, on the first layer of `doc`. +fn brushed(doc: &mut Document, brush: &str, from: (f64, f64), to: (f64, f64), width: f64) { + let mut line = + Node::path(doc.alloc_id(), shapes::line(from.into(), to.into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), width)); + line.appearance.stroke_mut().unwrap().brush = Some(brush.into()); + put(doc, line); +} + +/// [`brush_library`] and a path with the Calligraphic brush and one with the Art brush in Tints. +fn brushes_document() -> Document { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0); + brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5); + doc +} + +/// Brush art `n` as a tree to compare with [`same_tree`]: each object's kind, its anchors and handles, +/// the fills and strokes that paint (a stroke with its width), and its members in order. +fn art_tree(n: &Node) -> serde_json::Value { + let kind = match &n.kind { + NodeKind::Path { .. } => "path", + NodeKind::Compound { .. } => "compound", + NodeKind::Group { clip: false, .. } => "group", + _ => "other", + }; + let anchors: Vec<[f64; 6]> = match &n.kind { + NodeKind::Path { path, .. } => { + path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| [a.h_in.x, a.h_in.y, a.p.x, a.p.y, a.h_out.x, a.h_out.y])).collect() + } + _ => vec![], + }; + let paints: Vec = n + .appearance + .items + .iter() + .filter(|i| !i.paint().is_none()) + .map(|i| match i { + AppearanceItem::Fill(f) => serde_json::json!(["fill", f.paint]), + AppearanceItem::Stroke(s) => serde_json::json!(["stroke", s.paint, s.width]), + }) + .collect(); + let members: Vec = n.children().map(|c| c.iter().map(|m| art_tree(m)).collect()).unwrap_or_default(); + serde_json::json!({"kind": kind, "anchors": anchors, "paints": paints, "members": members}) +} + +/// Are trees `a` and `b` ([`art_tree`]) the same but for numbers within the written numbers' +/// precision (1/1000)? +fn same_tree(a: &serde_json::Value, b: &serde_json::Value) -> bool { + use serde_json::Value; + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() <= 1e-3, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same_tree(p, q)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_tree(v, w))), + _ => a == b, + } +} + +/// The document's brushes come back in the Brushes panel's order with their settings and art: the +/// Calligraphic brush's channels, the Scatter brush's ranges, modes and Tints, the Art brushes' +/// colorizations. +#[test] +fn brushes_are_read_back_with_their_settings() { + use vectorcraft_brush::BrushKind; + let doc = brushes_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let text = program(&doc); + assert_eq!(text.matches("%AI8_BeginPluginObject").count(), 5, "the manager's order and four brushes"); + assert_eq!(text.matches("%AI8_BeginBrushPattern").count(), 3); + let back = vectorcraft_brush::library(&read_back(&doc)); + let lib = brush_library(); + assert_eq!(back.iter().map(|b| b.name.as_str()).collect::>(), ["Nib", "Spray", "Ribbon", "Plain ribbon"]); + assert_eq!(back[0], lib[0]); + let (BrushKind::Scatter(got), BrushKind::Scatter(want)) = (&back[1].kind, &lib[1].kind) else { panic!("scatter") }; + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + assert!( + near(got.size, want.size) && near(got.spacing, want.spacing) && near(got.scatter, want.scatter) && near(got.rotation, want.rotation), + "{got:?}" + ); + assert_eq!((got.modes, got.colorization, got.rotation_relative_to_path), (want.modes, want.colorization, true)); + let (g, w) = (art_tree(&got.art), art_tree(&want.art)); + assert!(same_tree(&g, &w), "the art without its frame, with its paint and members in order: {g:#} for {w:#}"); + for (i, colorization) in [(2, vectorcraft_brush::Colorization::Tints), (3, vectorcraft_brush::Colorization::None)] { + let BrushKind::Art(a) = &back[i].kind else { panic!("art") }; + assert_eq!(a.colorization, colorization); + let (g, w) = (art_tree(&a.art), art_tree(&brush_art_node())); + assert!(same_tree(&g, &w), "the narrow block stays below the wide one, and no frame stays: {g:#} for {w:#}"); + let BrushKind::Art(want) = &lib[i].kind else { panic!("art") }; + assert_eq!(ArtBrushOptions::of(a), ArtBrushOptions::of(want)); + } +} + +/// An Art brush's options, without its art. +#[derive(Debug, PartialEq)] +struct ArtBrushOptions(serde_json::Value); + +impl ArtBrushOptions { + fn of(a: &vectorcraft_brush::ArtBrush) -> Self { + let mut v = serde_json::to_value(a).unwrap(); + v.as_object_mut().unwrap().remove("art"); + Self(v) + } +} + +/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the +/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn +/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look. +#[test] +fn brushed_paths_are_read_back_brushed() { + let mut doc = brushes_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 2); + for (n, (brush, width)) in art.iter().zip([("Nib", 2.0), ("Ribbon", 1.5)]) { + assert!(matches!(n.kind, NodeKind::Path { .. }), "{brush}: the path, not its look: {n:#?}"); + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some(brush), width, Some(Color::rgb(1.0, 0.0, 0.0)))); + assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none())); + } + let ours = doc.layers[0].children().unwrap(); + assert_eq!(art[0].path_data(), ours[0].path_data()); + assert_eq!(art[1].path_data(), ours[1].path_data()); + brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. })); +} + +/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its +/// stroke; a filled ellipse with it comes back with its fill and the brush. +#[test] +fn brushed_compound_and_filled_paths_are_read_back_brushed() { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 2.0); + stroke.brush = Some("Nib".into()); + let ring = ring(&mut doc, Appearance { items: vec![AppearanceItem::Stroke(stroke.clone())], effects: vec![], contents_index: None }); + put(&mut doc, ring); + let fill = vectorcraft_doc::FillLayer::new(solid(Color::rgb(0.2, 0.6, 0.3))); + let ap = Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None }; + let ellipse = Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(20.0, 120.0, 140.0, 180.0)), ap); + put(&mut doc, ellipse); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES || w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let (art, ours) = (back.layers[0].children().unwrap(), doc.layers[0].children().unwrap()); + assert!(matches!(art[0].kind, NodeKind::Compound { .. }), "the compound path, not its look: {:#?}", art[0]); + assert!(same_tree(&art_tree(&art[0])["members"], &art_tree(&ours[0])["members"]), "{:#?}", art[0]); + assert!(matches!(art[1].kind, NodeKind::Path { .. }), "the ellipse, not its look: {:#?}", art[1]); + assert!(same_tree(&art_tree(&art[1])["anchors"], &art_tree(&ours[1])["anchors"]), "{:#?}", art[1]); + assert_eq!(art[1].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.2, 0.6, 0.3))); + for n in art { + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width), (Some("Nib"), 2.0)); + } +} + +/// A path with an Art brush in Tints whose art doesn't bend along the path (its art has a painted +/// stroke) comes back as its drawn look, with the stroke warning; the brush itself comes back. +#[test] +fn an_art_brush_whose_art_doesnt_bend_keeps_its_paths_look() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Colorization}; + let mut art = brush_art_node(); + if let Some(c) = art.children_mut() { + for m in c.iter_mut() { + Arc::make_mut(m).appearance = Appearance::basic(black_fill(), black_fill(), 1.0).into(); + } + } + let brush = + Brush { name: "Stroked ribbon".into(), kind: BrushKind::Art(ArtBrush { art, colorization: Colorization::Tints, ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, std::slice::from_ref(&brush)); + brushed(&mut doc, "Stroked ribbon", (20.0, 120.0), (280.0, 150.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Group { .. })); + let lib = vectorcraft_brush::library(&back); + let (BrushKind::Art(got), BrushKind::Art(want)) = (&lib[0].kind, &brush.kind) else { panic!("art") }; + assert!(same_tree(&art_tree(&got.art), &art_tree(&want.art)), "{:#?}", got.art); +} + +/// Settings the other tests don't use come back: a Calligraphic brush varying with tilt and bearing, +/// a Scatter brush keeping its art's colors whose rotation doesn't follow the path, and brushes whose +/// modes aren't given (the reader gives them, the same ones). +#[test] +fn more_brush_settings_are_read_back() { + use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + let tilted = Calligraphic { + angle: -40.0, + roundness: 75.0, + size: 9.0, + variation: [10.0, 5.0, 3.0], + modes: Some([Variation::Tilt, Variation::Bearing, Variation::Random]), + }; + let scatter = Scatter { + art: brush_art_node(), + size: (80.0, 80.0), + spacing: (100.0, 100.0), + scatter: (0.0, 0.0), + rotation: (15.0, 15.0), + rotation_relative_to_path: false, + colorization: Colorization::None, + modes: Some([Variation::Tilt, Variation::Bearing, Variation::Fixed, Variation::Rotation]), + }; + let lib = vec![ + Brush { name: "Tilted".into(), kind: BrushKind::Calligraphic(tilted.clone()) }, + Brush { name: "Still".into(), kind: BrushKind::Scatter(scatter.clone()) }, + Brush { name: "Unset nib".into(), kind: BrushKind::Calligraphic(Calligraphic { modes: None, ..tilted }) }, + Brush { name: "Unset spray".into(), kind: BrushKind::Scatter(Scatter { modes: None, ..scatter }) }, + ]; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), lib.len()); + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + for (got, want) in back.iter().zip(&lib) { + assert_eq!(got.name, want.name); + match (&got.kind, &want.kind) { + (BrushKind::Calligraphic(g), BrushKind::Calligraphic(w)) => { + assert_eq!(g.modes(), w.modes(), "{}", want.name); + assert_eq!(Calligraphic { modes: None, ..g.clone() }, Calligraphic { modes: None, ..w.clone() }, "{}", want.name); + } + (BrushKind::Scatter(g), BrushKind::Scatter(w)) => { + assert_eq!(g.modes(), w.modes(), "{}", want.name); + assert!(near(g.size, w.size) && near(g.spacing, w.spacing) && near(g.scatter, w.scatter) && near(g.rotation, w.rotation), "{g:?}"); + assert_eq!((g.colorization, g.rotation_relative_to_path), (Colorization::None, false), "{}", want.name); + assert!(same_tree(&art_tree(&g.art), &art_tree(&w.art)), "{}: {:#?}", want.name, g.art); + } + _ => panic!("{}: {got:?}", want.name), + } + } +} + +/// Brush art without width or height, a straight stroked line, comes back as that line, without a +/// frame or a group around it: a Scatter brush's horizontal line and an Art brush's vertical one. +#[test] +fn brush_art_of_a_straight_line_comes_back_as_the_line() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter}; + let line = |id: u64, to: (f64, f64)| { + Node::path(vectorcraft_doc::NodeId(id), shapes::line((0.0, 0.0).into(), to.into()), Appearance::basic(Paint::None, black_fill(), 2.0)) + }; + let lib = vec![ + Brush { name: "Dash".into(), kind: BrushKind::Scatter(Scatter { art: line(1, (40.0, 0.0)), ..Scatter::default() }) }, + Brush { name: "Post".into(), kind: BrushKind::Art(ArtBrush { art: line(2, (0.0, 40.0)), ..ArtBrush::default() }) }, + ]; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 2); + for (got, want) in back.iter().zip(&lib) { + let art = |b: &Brush| match &b.kind { + BrushKind::Scatter(s) => s.art.clone(), + BrushKind::Art(a) => a.art.clone(), + _ => panic!("{}", b.name), + }; + let (g, w) = (art_tree(&art(got)), art_tree(&art(want))); + assert!(same_tree(&g, &w), "{}: {g:#} for {w:#}", want.name); + } +} + +/// Brush art of a path with several subpaths is written as a compound path, which the reader reads as +/// a compound path of one path for each subpath, and is weighed as the reader weighs that against the +/// art it copies into one file's brushes: of two Art brushes whose art is a path of 30,000 squares, +/// the second is left out with a warning, so that what is written is what comes back. +#[test] +fn brush_art_of_a_path_with_subpaths_is_weighed_as_it_is_read() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let squares = |n: u32| { + let mut path = PathData::default(); + for i in 0..n { + let x = f64::from(i % 200) * 2.0; + let y = f64::from(i / 200) * 2.0; + path.subpaths.extend(shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths); + } + Node::path(vectorcraft_doc::NodeId(1), path, Appearance::basic(black_fill(), Paint::None, 0.0)) + }; + let brush = |name: &str, n: u32| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Two", 2)]); + let back = vectorcraft_brush::library(&read_back(&doc)); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + let tree = art_tree(&a.art); + assert_eq!(tree["kind"], "compound", "{tree:#}"); + assert_eq!(tree["members"].as_array().map(Vec::len), Some(2), "{tree:#}"); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Many 1", 30_000), brush("Many 2", 30_000)]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let names: Vec = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Many 1"]); + assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 1); +} + +/// Brush art that is a compound path is written as one path of all its members' subpaths, and is +/// weighed as the reader reads that back: a compound path of 24,000 members of two squares each is +/// read as 48,000 paths, within what the reader copies into one file's brushes, so the brush is +/// written without a warning; a compound path with one square comes back as one path. +#[test] +fn brush_art_of_a_compound_path_is_weighed_as_it_is_read() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let square = |i: u32| { + let x = f64::from(i % 400) * 2.0; + let y = f64::from(i / 400) * 2.0; + shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths + }; + let compound = |members: u32, per: u32| { + let children = (0..members) + .map(|m| { + let path = PathData::new((0..per).flat_map(|k| square(m * per + k)).collect()); + Arc::new(Node::path(vectorcraft_doc::NodeId(2), path, Appearance::basic(black_fill(), Paint::None, 0.0))) + }) + .collect(); + let mut n = Node::path(vectorcraft_doc::NodeId(1), PathData::default(), Appearance::basic(black_fill(), Paint::None, 0.0)); + n.kind = NodeKind::Compound { children, rule: FillRule::NonZero }; + n + }; + let brush = |name: &str, art: Node| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("One", compound(1, 1))]); + let back = vectorcraft_brush::library(&read_back(&doc)); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + assert_eq!(art_tree(&a.art)["kind"], "path", "{:#}", art_tree(&a.art)); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Many", compound(24_000, 2))]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 1); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + assert_eq!(a.art.children().map(Vec::len), Some(48_000)); +} + +/// A path with a drop shadow and the Calligraphic brush keeps the brush and the effect, and its drawn +/// look doesn't warn about the brushed stroke; a group with a drop shadow whose member has the Scatter +/// brush still warns, as the member is written as its brush's art. +#[test] +fn a_brushed_path_with_effects_keeps_its_brush_without_the_stroke_warning() { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut line = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some("Nib".into()); + line.appearance.effects = vec![drop_shadow()]; + put(&mut doc, line); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Path { .. }), "the path, not its look: {n:#?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Nib")); + assert_eq!(n.appearance.effects.len(), 1); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut member = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0), + ); + member.appearance.stroke_mut().unwrap().brush = Some("Spray".into()); + let mut g = group(&mut doc, vec![member], false); + g.appearance.effects = vec![drop_shadow()]; + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); +} + +/// A brushed stroke that paints nothing isn't kept by an art style: a path with only such a stroke is +/// written without a style, and a document whose brushes are a new document's writes no brushes. +#[test] +fn a_brushed_stroke_that_paints_nothing_writes_no_brushes() { + let mut doc = Document::new(300.0, 200.0); + let mut line = + Node::path(doc.alloc_id(), shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), Appearance::basic(Paint::None, Paint::None, 1.0)); + line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + put(&mut doc, line); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("%AI8_BeginPluginObject"), "no brushes"); + assert!(!text.contains("/KnownStyle"), "no art style"); +} + +/// The lines of `program` that hold brushes and what is drawn with them: the brush art, the plug-in +/// objects, the art styles and the layers. +fn brush_lines(program: &str) -> Vec<&str> { + let mut out = vec![]; + let mut inside = false; + for line in program.lines() { + if ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "%%EndSetup"].iter().any(|m| line.starts_with(m)) { + inside = true; + } + if inside { + out.push(line); + } + if ["%AI8_EndBrushPattern", "%AI8_EndPluginObject", "%AI9_EndArtStyles"].iter().any(|m| line.starts_with(m)) { + inside = false; + } + } + out +} + +/// A document with brushes and brushed paths is written as the same bytes again, and what the reader +/// reads back is written with the same brushes, art styles and layers. +#[test] +fn brushes_write_the_same_bytes_again() { + let doc = brushes_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap(); + let first = String::from_utf8(first.program).unwrap(); + let (again, first) = (brush_lines(&again), brush_lines(&first)); + let differ = again.iter().zip(&first).enumerate().find(|(_, (a, b))| a != b); + assert_eq!(differ, None, "the first line that differs"); + assert_eq!(again.len(), first.len()); +} + +/// Brushes the reader wouldn't read back as they are are left out with a warning, each on its own +/// beside a brush that comes back, and each changed from a brush that comes back by the one setting +/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose +/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle +/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that +/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a +/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its +/// name from a later one. +#[test] +fn brushes_the_reader_cant_read_are_left_out() { + use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Colorization, Direction, PatternBrush, Variation}; + let lib = brush_library(); + let keep = lib[0].clone(); + let (BrushKind::Scatter(scatter), BrushKind::Art(art)) = (&lib[1].kind, &lib[2].kind) else { panic!("library") }; + let hue = Colorization::HueShift { key: Color::rgb(1.0, 0.0, 0.0) }; + let art_with = |f: &dyn Fn(&mut ArtBrush)| { + let mut a = art.clone(); + f(&mut a); + BrushKind::Art(a) + }; + let mut painted_group = brush_art_node(); + painted_group.appearance = Appearance::basic(black_fill(), Paint::None, 0.0).into(); + let empty_path = Node::path(vectorcraft_doc::NodeId(1), PathData::new(vec![]), Appearance::basic(black_fill(), Paint::None, 0.0)); + let cases: Vec<(&str, BrushKind)> = vec![ + ("painted group", art_with(&|a| a.art = painted_group.clone())), + ("empty path", BrushKind::Scatter(vectorcraft_brush::Scatter { art: empty_path.clone(), ..scatter.clone() })), + ("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))), + ("pattern", BrushKind::Pattern(PatternBrush::default())), + ("bristle", BrushKind::Bristle(Bristle::default())), + ("flipped along", art_with(&|a| a.flip_along = true)), + ("flipped across", art_with(&|a| a.flip_across = true)), + ("overlap", art_with(&|a| a.overlap = true)), + ("width", art_with(&|a| a.width = 50.0)), + ("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))), + ("direction", art_with(&|a| a.direction = Direction::RightToLeft)), + ("proportional", art_with(&|a| a.scale = ArtScale::Proportional)), + ("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)), + ("art hue shift", art_with(&|a| a.colorization = hue)), + ( + "scatter tints and shades", + BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: Colorization::TintsAndShades, ..scatter.clone() }), + ), + ("scatter hue shift", BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: hue, ..scatter.clone() })), + ]; + let left_out = |extra: Vec, why: &str| { + let mut doc = Document::new(300.0, 200.0); + let mut lib = vec![keep.clone()]; + lib.extend(extra); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{why}: {:?}", out.warnings); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), std::slice::from_ref(&keep), "{why}"); + }; + for (why, kind) in cases { + left_out(vec![Brush { name: "Other".into(), kind }], why); + } + left_out(vec![Brush { name: "A/B".into(), ..keep.clone() }], "slash"); + left_out(vec![Brush { kind: BrushKind::Bristle(Bristle::default()), ..keep.clone() }], "a second brush of the name"); + // A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back. A + // path with "X" is drawn with the Pattern brush, so it comes back as its drawn look. + let mut doc = Document::new(300.0, 200.0); + let x = Brush { name: "X".into(), ..keep.clone() }; + vectorcraft_brush::store(&mut doc, &[Brush { name: "X".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, x.clone()]); + brushed(&mut doc, "X", (20.0, 50.0), (280.0, 60.0), 2.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(vectorcraft_brush::library(&back), [x]); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.stroke().is_none_or(|s| s.brush.is_none()), "not with the other brush of the name: {n:#?}"); +} + +/// Brushes past the most the reader reads from one file are left out with a warning: of 4,097 +/// Calligraphic brushes, the first 4,096 come back. +#[test] +fn brushes_past_the_readers_count_are_left_out() { + let nib = brush_library().remove(0); + let lib: Vec = (0..4097).map(|i| vectorcraft_brush::Brush { name: format!("Nib {i}"), ..nib.clone() }).collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 4096); + assert_eq!(back.last().map(|b| b.name.as_str()), Some("Nib 4095")); +} + +/// A brush whose art the editing data would change (a stroke inside the path, written as its +/// outline) is left out, also after an object on the layers was already written with that change. +#[test] +fn a_brush_whose_art_would_change_is_left_out() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let inside = |doc: &mut Document| { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), Appearance::basic(Paint::None, black_fill(), 2.0)); + n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside; + n + }; + let mut doc = Document::new(300.0, 200.0); + let on_layer = inside(&mut doc); + put(&mut doc, on_layer); + let keep = brush_library().remove(0); + let art = inside(&mut doc); + vectorcraft_brush::store( + &mut doc, + &[keep.clone(), Brush { name: "Outlined".into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }], + ); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), [keep]); +} + +/// Brushes past the art the reader copies into one file's brushes are left out with a warning, so +/// that what is written is what comes back: three Art brushes of 100,000 anchors each, of which the +/// reader keeps two. +#[test] +fn brushes_past_the_readers_art_limit_are_left_out() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let heavy = |id: u64| { + let points: Vec = + (0..100_000).map(|i| vectorcraft_geom::Point::new(f64::from(i) / 1000.0, if i % 2 == 0 { 0.0 } else { 4.0 })).collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Node::path(vectorcraft_doc::NodeId(id), path, Appearance::basic(black_fill(), Paint::None, 0.0)) + }; + let lib: Vec = + (1..=3).map(|i| Brush { name: format!("Heavy {i}"), kind: BrushKind::Art(ArtBrush { art: heavy(i), ..ArtBrush::default() }) }).collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back: Vec = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect(); + assert_eq!(back, ["Heavy 1", "Heavy 2"]); + assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 2); + // The art of the brush left out isn't counted: there is room for it all with room for what is read. + let read = crate::import::objects_read(&out.program).unwrap(); + let room = export_within(&doc, &opts(), read).unwrap(); + assert!(!room.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{read}: {:?}", room.warnings); +} + +/// A brush whose art doesn't fit within the object limit is left out with the limit's warning, and +/// what its art took is given back: a later brush whose art fits comes back. +#[test] +fn a_brush_whose_art_passes_the_object_limit_leaves_room_for_the_next() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let squares = |n: u64| { + let block = |i: u64| { + let x = i as f64 * 10.0; + Arc::new(Node::path( + vectorcraft_doc::NodeId(i + 1), + shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0)), + Appearance::basic(black_fill(), Paint::None, 0.0), + )) + }; + Node::group(vectorcraft_doc::NodeId(100), (0..n).map(block).collect()) + }; + let art = |name: &str, n: u64| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[art("Big", 6), art("Small", 2)]); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let mut small = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut small, &[art("Small", 2)]); + let room = crate::import::objects_read(&export_ai_editing(&small, &opts()).unwrap().program).unwrap(); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert!(crate::import::objects_read(&out.program).unwrap() <= room); + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let back = crate::ai_alone(&data).unwrap().0; + let names: Vec = vectorcraft_brush::library(&back).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Small"]); +} + +/// A graphic style with a brushed stroke, which is left out, takes no room from the layers' objects: +/// a document with a new document's brushes and no brushed path writes no brush art, so with room for +/// what its layers write, every object comes back. +#[test] +fn a_graphic_style_with_a_brush_takes_no_room_from_the_layers() { + let mut doc = Document::new(300.0, 200.0); + for i in 0..3 { + let x = 20.0 + f64::from(i) * 40.0; + let square = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(x, 20.0, x + 30.0, 50.0)), Appearance::basic(black_fill(), Paint::None, 0.0)); + put(&mut doc, square); + } + let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 1.0); + stroke.brush = Some("3 pt. Round".into()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None }; + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Brushed", ap)); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{room}: {:?}", out.warnings); + let program = String::from_utf8(out.program).unwrap(); + assert!(!program.contains("%AI8_BeginBrushPattern"), "no brush art"); +} + +/// A graphic style whose brushed stroke paints nothing comes back in the Graphic Styles panel. +#[test] +fn a_graphic_style_with_an_unpainted_brushed_stroke_comes_back() { + let mut doc = Document::new(300.0, 200.0); + let mut ap = Appearance::basic(black_fill(), Paint::None, 1.0); + ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Unpainted brush", ap)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + assert!(read_back(&doc).graphic_style("Unpainted brush").is_some()); +} + +/// A brush whose art doesn't fit within the object limit after a path was written with it on its +/// stroke is left out with both warnings, and the path comes back as its drawn look. +#[test] +fn a_brushed_path_whose_brush_art_doesnt_fit_comes_back_as_its_look() { + let ribbon = brush_library().remove(2); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, std::slice::from_ref(&ribbon)); + brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::BRUSHES), "{:?}", all.warnings); + let room = crate::import::objects_read(&all.program).unwrap() - 1; + let out = export_within(&doc, &opts(), room).unwrap(); + for w in [writer::OBJECT_LIMIT, writer::BRUSHES] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } + assert!(crate::import::objects_read(&out.program).unwrap() <= room); + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let back = crate::ai_alone(&data).unwrap().0; + assert!(vectorcraft_brush::library(&back).is_empty()); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Group { .. }), "the path's drawn look: {n:#?}"); +} + +/// An empty Brushes panel comes back empty, and a document with a new document's brushes and no +/// brushed path writes no brushes (the reader gives such a file a new document's). +#[test] +fn an_empty_brushes_panel_comes_back_empty() { + let mut doc = Document::new(300.0, 200.0); + assert!(!program(&doc).contains("%AI8_BeginPluginObject")); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), vectorcraft_brush::library(&doc)); + vectorcraft_brush::store(&mut doc, &[]); + assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty()); +} + +/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its +/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle +/// brushes are left out with a warning, so the file opens without them. +#[test] +fn a_new_documents_brush_on_a_path_writes_the_brushes() { + let mut doc = Document::new(300.0, 200.0); + brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round")); + let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"]; + let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect(); + let lib = vectorcraft_brush::library(&back); + assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::>(), want); + assert_eq!(vectorcraft_brush::defaults().len(), want.len() + left_out.len()); +} + +/// A path with one of a new document's brushes that doesn't fit within the object limit is left +/// out, and no brushes are written: the file opens with a new document's brushes, all of them. +#[test] +fn a_new_documents_brush_on_a_path_past_the_object_limit_writes_no_brushes() { + let empty = Document::new(300.0, 200.0); + let room = crate::import::objects_read(&export_ai_editing(&empty, &opts()).unwrap().program).unwrap(); + let mut doc = Document::new(300.0, 200.0); + brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("%AI8_BeginPluginObject")); +} + +/// On a large canvas the brushes are left out with a warning, and brushed paths come back as their +/// drawn look. +#[test] +fn a_large_canvas_leaves_the_brushes_out() { + let mut doc = Document::new(20000.0, 100.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(!program(&doc).contains("%AI8_BeginPluginObject")); + assert!(matches!(read_back(&doc).layers[0].children().unwrap()[0].kind, NodeKind::Group { .. })); + // An empty panel is kept, without a warning. + let mut doc = Document::new(20000.0, 100.0); + vectorcraft_brush::store(&mut doc, &[]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty()); +} + // ---------- variables ---------- /// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility @@ -7676,3 +8657,144 @@ fn diamond_gradients_are_written_as_radial_gradients_through_their_corners() { let Some(Paint::Gradient(swatch)) = back.swatch("Gem").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert_eq!(swatch.gradient.kind, GradientKind::Radial); } + +/// The data has no spread: a fill that repeats or reflects holds its stops repeated across the +/// shape and reads back looking as the canvas paints it; a swatch holds its stops repeated along +/// its vector; both with a warning. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::GradientSpread; + for kind in [GradientKind::Linear, GradientKind::Radial] { + 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 mut g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::new(kind, stops) }); + let (start, end) = (vectorcraft_geom::Point::new(60.0, 50.0), vectorcraft_geom::Point::new(120.0, 50.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g.clone())); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))) + .unwrap(); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { + name: "Bands".into(), + paint: Paint::Gradient(Box::new(GradientPaint { geom: None, ..g })), + global: false, + spot: false, + }); + doc.swatches = swatches.into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!( + out.warnings.iter().any(|w| w.contains("gradients that repeat or reflect are written with their stops repeated")), + "{:?}", + out.warnings + ); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.02 * st.pixels as f64, "{kind:?}: {} of {} pixels differ", st.bad_pixels, st.pixels); + let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; + let colors: Vec = swatch.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); + assert_eq!(colors, ["#ff0000", "#0000ff", "#0000ff", "#ff0000"], "{kind:?}"); + } +} + +/// A stroke's gradient that repeats is written with its stops repeated as far as the stroke's +/// miter joins reach, past the box the gradient is placed on, so a sharp join's tip shows the next +/// run rather than the color the gradient pads with; a straight line, which has no joins, keeps +/// every run within the stops the reader reads. +#[test] +fn a_stroke_spread_gradient_runs_on_into_its_miter_tips() { + use vectorcraft_color::GradientSpread; + 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 mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(GradientKind::Linear, stops) }); + // From the top of the box the gradient is placed on (the path's box grown by half the width). + let (start, end) = (vectorcraft_geom::Point::new(100.0, 15.0), vectorcraft_geom::Point::new(100.0, 85.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let mut app = Appearance::basic(Paint::None, Paint::Gradient(Box::new(g)), 10.0); + for item in &mut app.items { + if let AppearanceItem::Stroke(s) = item { + (s.join, s.miter_limit) = (LineJoin::Miter, 10.0); + } + } + // A V whose apex's miter reaches about 10 pt above it, to y = 9.9. + let pts = [vectorcraft_geom::Point::new(60.0, 90.0), vectorcraft_geom::Point::new(100.0, 20.0), vectorcraft_geom::Point::new(140.0, 90.0)]; + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)]), app)).unwrap(); + let back = read_back(&doc); + let mut geom = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.stroke_paint() { + geom = g.geom; + } + }); + let geom = geom.expect("the stroke keeps a gradient"); + assert!(geom.start.y.min(geom.end.y) < 9.9, "the runs start at {:?}", geom); + // A straight line has no joins: its 100 runs, 200 stops, fit the 256 the reader reads. + 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 mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + let (start, end) = (vectorcraft_geom::Point::new(50.0, 50.0), vectorcraft_geom::Point::new(150.0, 50.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let line = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[start, end], false)]); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, line, Appearance::basic(Paint::None, Paint::Gradient(Box::new(g)), 10.0))).unwrap(); + let mut stops = None; + read_back(&doc).walk(|n| { + if let Paint::Gradient(g) = n.appearance.stroke_paint() { + stops = Some(g.gradient.stops.len()); + } + }); + assert_eq!(stops, Some(200)); +} + +/// A conic fill (written linear, its stops repeated around its turn), a diamond fill (written +/// radial) and a swatch whose stops repeat more times than the 256 stops the reader reads stay +/// gradients: each holds its stops repeated as far as 256 stops go. +#[test] +fn spread_gradients_past_the_stops_the_reader_reads_stay_gradients() { + use vectorcraft_color::GradientSpread; + let three = || { + vec![ + GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), + GradientStop::new(0.5, Color::rgb(0.0, 1.0, 0.0)), + GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)), + ] + }; + for kind in [GradientKind::Conic, GradientKind::Diamond] { + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(kind, three()) }); + let c = vectorcraft_geom::Point::new(100.0, 50.0); + g.geom = Some(vectorcraft_color::GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(40.0, 0.0), aspect: 1.0, focal: None }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g)); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))) + .unwrap(); + let back = read_back(&doc); + let mut stops = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + stops = Some(g.gradient.stops.len()); + } + }); + let stops = stops.unwrap_or_else(|| panic!("{kind:?}: the gradient is written as a solid color")); + assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&stops), "{kind:?}: {stops} stops"); + } + // Stops at 0.1 and 0.9 run from 0 to 1 as four: 70 runs are 280 stops. + let stops = vec![GradientStop::new(0.1, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(0.9, Color::rgb(0.0, 0.0, 1.0))]; + let g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 70, ..Gradient::new(GradientKind::Linear, stops) }); + let mut doc = Document::new(200.0, 100.0); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Bands".into(), paint: Paint::Gradient(Box::new(g)), global: false, spot: false }); + doc.swatches = swatches.into(); + let back = read_back(&doc); + let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; + assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&swatch.gradient.stops.len()), "{} stops", swatch.gradient.stops.len()); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..11787f495f 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -15,6 +15,7 @@ use vectorcraft_pathops::BoolOp; use crate::ai_ops::{self, unit}; use crate::ps; +mod brushes; mod images; mod paints; mod styles; @@ -22,6 +23,8 @@ mod symbols; mod texts; mod variables; +#[cfg(test)] +pub(super) use brushes::BRUSHES; pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; @@ -51,7 +54,7 @@ pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths tha pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; -const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps"; @@ -255,6 +258,10 @@ pub(super) struct Writer<'a> { /// An object's drawn look is being written: no object in it is written with a style, and its /// effects are the style's. in_look: bool, + /// The brushes written ([`brushes`]). + brush_set: brushes::Brushes, + /// Brushes may be written; false on a large canvas, where they are left out. + pub(super) brushes_live: bool, } impl<'a> Writer<'a> { @@ -310,6 +317,8 @@ impl<'a> Writer<'a> { styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, + brush_set: brushes::Brushes::default(), + brushes_live: true, objects: 0, left_out: 0, definition_objects: 0, @@ -896,7 +905,7 @@ impl<'a> Writer<'a> { self.warn_cut(Some(first)); return self.clip_contents(children); }; - let items = self.items(first, path.to_bezpath().bounding_box()); + let items = self.items(first, &path.to_bezpath()); let painted = self.plain_paint(&items); // The clipping path goes after the others, so its objects are counted first. let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); @@ -1004,7 +1013,7 @@ impl<'a> Writer<'a> { if path.is_empty() { return; } - let items = self.items(n, path.to_bezpath().bounding_box()); + let items = self.items(n, &path.to_bezpath()); if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { self.warn(ITEM_TRANSPARENCY); } @@ -1028,7 +1037,13 @@ impl<'a> Writer<'a> { AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy), AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { - self.warn(STROKES); + // In a styled path's or compound path's drawn look the stroke, brushed or not, is kept + // by the object's art style, which is written only when the reader reads the object's + // appearance (and its brush) back from it. A styled group's style holds no fills or + // strokes: its members are written again after the look, and warn there. + if !self.in_look { + self.warn(STROKES); + } self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; @@ -1343,8 +1358,9 @@ impl<'a> Writer<'a> { self.plain_color(*color, stroke); } - /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and - /// Lab as RGB), with its CMYK equivalent. + /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB in the + /// working RGB space, and Lab as display RGB), with its CMYK equivalent (an RGB color's from its + /// values as written, a Lab color's from its Lab values). fn plain_color(&mut self, color: Color, stroke: bool) { let s = &mut self.body; let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; @@ -1359,19 +1375,22 @@ impl<'a> Writer<'a> { let _ = writeln!(s, "{}", op("k", "K")); } Color::Rgb { .. } | Color::Lab { .. } => { - let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); let _ = writeln!(s, "{}", op("Xa", "XA")); } } } /// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to - /// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full - /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before - /// `Xx` (spot) or `Xk` (global process). + /// `s`: its CMYK equivalent (from its RGB or Lab values as written), its RGB or Lab values + /// (clamped as [`ai_ops::as_written`] clamps them), the name, how much lighter than the full + /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before `Xx` + /// (spot) or `Xk` (global process). fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) { + let full = ai_ops::as_written(full); let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let model = match full { @@ -1467,7 +1486,8 @@ impl<'a> Writer<'a> { true } - /// The spot color swatches, with their full colors' CMYK equivalents, for the header. + /// The spot color swatches, with their full colors' CMYK equivalents (as the palette writes + /// them), for the header. pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> { let intent = self.cms.settings().intent; self.doc @@ -1475,7 +1495,7 @@ impl<'a> Writer<'a> { .filter(|sw| sw.spot) .filter_map(|sw| match sw.paint { Paint::Solid { color, swatch: None, .. } => { - Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit))) + Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(ai_ops::as_written(color), true), intent).map(unit))) } _ => None, }) diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs new file mode 100644 index 0000000000..2c281e702d --- /dev/null +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -0,0 +1,558 @@ +//! Brushes: the document's brushes as the `.ai` reader reads them ([`crate::import`]'s `.ai` reader, +//! `brush.rs`), and the brush filter of a brushed stroke in an art style (`style.rs`). +//! +//! Each brush is a plug-in object (`%AI8_BeginPluginObject`, the plug-in's name, the brush's name, +//! its parameters as strings that `-` continues and `.` ends, `%AI8_EndPluginObject`), after the +//! brush manager's order of them all, in a non-printing block after the symbols. The art of a +//! Scatter or Art brush is a `%AI8_BeginBrushPattern` section (its name; a group of the art's frame, +//! an unpainted rectangle at the art's geometric bounds, and the art, or the art alone when it has no +//! width or height; its end) in a non-printing block before the symbols. Each brush is checked with +//! the reader's own reading ([`crate::import::read_brush_object`]) before it is written: one the +//! reader would read as another brush is left out. + +use std::collections::HashSet; +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Calligraphic, Colorization, Direction, Scatter, Variation}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, PathData, SubPath}; + +use super::{Writer, Xy}; +use crate::ai_ops; +use crate::ps; + +pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps"; + +/// Most brushes written (the reader's limit). +const MAX_BRUSHES: usize = 4096; + +/// Most bytes of a plug-in object's parameters in one of its strings. +const CHUNK: usize = 60; + +/// A brush written as a plug-in object. +pub(super) struct BrushOut { + pub brush: Brush, + pub plugin: &'static str, + pub params: String, + /// The art its parameters name, as the reader reads it. + pub art: Option, + /// Its art section to write ([`Writer::brush_blocks`] writes it). + section: Option, + /// A path whose art style names it at its own settings is read with the brush on its stroke. + pub live: bool, +} + +/// A brush's art section to write: its name, the art and the space it is written in +/// ([`Writer::brush_art_section`]). +type SectionArt = (String, Node, Affine); + +/// The brushes written and their art. +#[derive(Default)] +pub(super) struct Brushes { + /// The document's brushes have been worked out. + done: bool, + pub written: Vec, + /// The art sections named so far. + sections: usize, + /// A brush of the document was left out. + left_out: bool, + /// How many objects were written with a brush on their stroke. + pub live_written: usize, + /// The brushes written as the reader reads them, for checking art styles; made once, when + /// the first style is checked ([`Writer::brushed_style_reads`]). + read: Option, +} + +/// A number with at most six decimals, as brush parameters hold factors. +fn num6(s: &mut String, v: f64) { + let v = if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }; + if v == v.trunc() && v.abs() < 1e15 { + let _ = write!(s, "{}", v as i64); + return; + } + let start = s.len(); + let _ = write!(s, "{v:.6}"); + let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len(); + s.truncate(start + keep); +} + +/// `vs` as [`num6`] numbers separated by spaces. +fn nums6(vs: &[f64]) -> String { + let mut s = String::new(); + for (i, v) in vs.iter().enumerate() { + if i > 0 { + s.push(' '); + } + num6(&mut s, *v); + } + s +} + +/// The code a brush's parameters give how a value varies, in the order of the Brush Options menu. +fn code(v: Variation) -> f64 { + match v { + Variation::Fixed => 0.0, + Variation::Random => 1.0, + Variation::Pressure => 2.0, + Variation::Tilt => 4.0, + Variation::Bearing => 5.0, + Variation::Rotation => 6.0, + } +} + +/// The colorization code of Scatter and Art brushes' parameters: `None` for one the reader doesn't read. +fn colorization_code(c: Colorization) -> Option { + match c { + Colorization::None => Some(0.0), + Colorization::Tints => Some(1.0), + Colorization::TintsAndShades | Colorization::HueShift { .. } => None, + } +} + +/// A Calligraphic brush's parameters: 1, the diameter, roundness and angle as (low, high) pairs, +/// the codes of how the diameter, roundness and angle vary, and 0. +fn calligraphic_params(c: &Calligraphic) -> String { + let [angle, roundness, size] = [c.angle, c.roundness, c.size]; + let [av, rv, dv] = c.variation; + let [am, rm, dm] = c.modes().map(code); + nums6(&[1.0, size - dv, size + dv, roundness - rv, roundness + rv, angle - av, angle + av, dm, rm, am, 0.0]) +} + +/// A Scatter brush's parameters, its art the section `art`: `None` for a colorization the reader +/// doesn't read. +fn scatter_params(s: &Scatter, art: &str) -> Option { + let f = |v: f64| v / 100.0; + let m = s.modes().map(code); + let numbers = [ + colorization_code(s.colorization)?, + f(s.size.0), + f(s.size.1), + m[0], + f(s.spacing.0), + f(s.spacing.1), + m[1], + f(s.scatter.0), + f(s.scatter.1), + m[2], + s.rotation.0, + s.rotation.1, + m[3], + f64::from(u8::from(s.rotation_relative_to_path)), + 0.0, + ]; + Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers))) +} + +/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read +/// (it reads an Art brush that stretches its art along the path from left to right at 100%, +/// unflipped, without overlap, in Tints or keeping its colors). +fn art_params(a: &ArtBrush, art: &str) -> Option { + let plain = a.direction == Direction::LeftToRight + && matches!(a.scale, ArtScale::Stretch) + && a.width == 100.0 + && a.width_mode.is_none() + && !a.flip_along + && !a.flip_across + && !a.overlap; + if !plain { + return None; + } + let c = colorization_code(a.colorization)?; + let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers))) +} + +/// Is `art` written as the reader reads it back, within a brush: paths and compound paths with +/// points and groups with members (not clipping, without paint of their own), each opaque in the +/// normal blending mode, without effects or opacity masks, with at most one fill and one plain +/// stroke (no brush, width profile, arrowheads, alignment off the path or dashes fitted to corners) +/// over it, each in a color or none? A path without points isn't written ([`Writer::plain_object`]). +fn plain_art(art: &Node) -> bool { + let mut plain = true; + art.walk(&mut |n| { + let kind = match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => path.subpaths.iter().any(|sp| !sp.anchors.is_empty()), + NodeKind::Compound { .. } => super::compound_path(n).subpaths.iter().any(|sp| !sp.anchors.is_empty()), + NodeKind::Group { children, clip: false } => !children.is_empty(), + _ => false, + }; + let items = &n.appearance.items; + let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); + let strokes = items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + let order = !matches!(items.as_slice(), [AppearanceItem::Stroke(_), AppearanceItem::Fill(_)]); + let quiet = items.iter().all(|i| { + i.effects().is_empty() + && i.opacity() >= 1.0 + && i.blend() == BlendMode::Normal + && matches!(i.paint(), Paint::Solid { .. } | Paint::None) + && !matches!(i, AppearanceItem::Stroke(s) if !vectorcraft_effects::stroke::is_plain(s)) + }); + // A group's own fills and strokes aren't written. + let group_paint = matches!(n.kind, NodeKind::Group { .. }) && items.iter().any(|i| !i.paint().is_none()); + plain &= kind + && !group_paint + && fills <= 1 + && strokes <= 1 + && order + && quiet + && n.appearance.effects.is_empty() + && n.extras().mask.is_none() + && n.opacity >= 1.0 + && n.blend == BlendMode::Normal; + }); + plain +} + +/// `art` (checked by [`plain_art`]) with its structure as the reader reads it back. A path, or a +/// compound path with the subpaths of all its members as one path ([`super::compound_path`]), is +/// written as one path when it has one subpath with points and as a compound path when it has more +/// ([`Writer::plain_object`]); the reader reads that compound path as one path for each subpath, +/// each with the object's paint. +fn as_read(art: &mut Node) { + let (subpaths, rule) = match &art.kind { + NodeKind::Path { path, rule, .. } => (path.subpaths.clone(), *rule), + NodeKind::Compound { rule, .. } => (super::compound_path(art).subpaths, *rule), + _ => { + if let Some(children) = art.children_mut() { + for c in children.iter_mut() { + as_read(Arc::make_mut(c)); + } + } + return; + } + }; + let mut subpaths: Vec = subpaths.into_iter().filter(|sp| !sp.anchors.is_empty()).collect(); + let path = |sp: SubPath| { + let mut p = Node::path(art.id, PathData::single(sp), Appearance::default()); + p.appearance = art.appearance.clone(); + p + }; + match subpaths.len() { + 0 => {} + 1 => { + let Some(sp) = subpaths.pop() else { return }; + art.kind = path(sp).kind; + if let NodeKind::Path { rule: r, .. } = &mut art.kind { + *r = rule; + } + } + _ => { + let children = subpaths.into_iter().map(|sp| Arc::new(path(sp))).collect(); + art.kind = NodeKind::Compound { children, rule }; + } + } +} + +/// Do brushes `a` and `b` have the same settings, within the written numbers' precision, whatever +/// their art and however their modes are given? +fn same_settings(a: &Brush, b: &Brush) -> bool { + let norm = |b: &Brush| -> serde_json::Value { + let mut b = b.clone(); + let none = || Node::group(NodeId(0), vec![]); + match &mut b.kind { + BrushKind::Calligraphic(c) => c.modes = Some(c.modes()), + BrushKind::Scatter(s) => { + s.modes = Some(s.modes()); + s.art = none(); + } + BrushKind::Art(a) => a.art = none(), + BrushKind::Pattern(_) | BrushKind::Bristle(_) => {} + } + serde_json::to_value(&b).unwrap_or(serde_json::Value::Null) + }; + super::styles::near(&norm(a), &norm(b)) +} + +/// A plug-in object: its plug-in's name, its name, and `params` in strings of at most [`CHUNK`] bytes. +fn plugin_object(plugin: &str, name: &str, params: &str) -> String { + let mut s = format!("%AI8_BeginPluginObject\n{}\n{}\n", ps::bytes(plugin.as_bytes()), ps::bytes(name.as_bytes())); + let chunks: Vec<&[u8]> = params.as_bytes().chunks(CHUNK).collect(); + if chunks.is_empty() { + s.push_str("() .\n"); + } + for (i, c) in chunks.iter().enumerate() { + let _ = writeln!(s, "{} {}", ps::bytes(c), if i + 1 == chunks.len() { "." } else { "-" }); + } + s.push_str("%AI8_EndPluginObject\n"); + s +} + +impl Writer<'_> { + /// Work out, once, which of the document's brushes are written: each one the reader reads back + /// with its settings. Their art is written with the brushes ([`Self::brush_blocks`]). + pub(super) fn brush_set(&mut self) { + if self.brush_set.done { + return; + } + self.brush_set.done = true; + let doc = self.doc; + let lib = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)).clone(); + if !self.brushes_live { + self.brush_set.left_out = !lib.is_empty(); + return; + } + // A name is taken by a brush written, as the reader takes it by a brush it keeps. A brush after + // another of its name isn't the one a path of that name is drawn with, so no path is written + // with it on its stroke. + let mut names: HashSet = HashSet::new(); + let mut seen: HashSet = HashSet::new(); + for b in lib { + let fits = !b.name.contains('/') && !names.contains(&b.name) && self.brush_set.written.len() < MAX_BRUSHES; + let first = seen.insert(b.name.clone()); + match fits.then(|| self.brush_out(&b)).flatten() { + Some(mut out) => { + names.insert(b.name.clone()); + out.live &= first; + self.brush_set.written.push(out); + } + None => self.brush_set.left_out = true, + } + } + // Read together, as one file's: the reader copies only so much art into one file's brushes. + let objects: Vec<(&str, &str, &str, Option<&Node>)> = + self.brush_set.written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect(); + let kept = crate::import::brushes_kept(&objects); + if kept.iter().any(|k| !k) { + self.brush_set.left_out = true; + let mut keep = kept.into_iter(); + self.brush_set.written.retain(|_| keep.next().unwrap_or(false)); + } + } + + /// Brush `b` as it is written, with its art section's name, art and placement + /// ([`Self::brush_art_section`] writes it): `None` when the reader wouldn't read it back. + fn brush_out(&mut self, b: &Brush) -> Option { + let plugin = crate::import::brush_plugin_name(&b.kind); + let section = format!("Brush Art {}", self.brush_set.sections + 1); + // The art as the reader reads it: a section is read with y down, and an Art brush's art is + // turned over after that (its section's y up is the right of the path). + let (params, art) = match &b.kind { + BrushKind::Calligraphic(c) => (calligraphic_params(c), None), + BrushKind::Scatter(s) => (scatter_params(s, §ion)?, Some((s.art.clone(), Affine::FLIP_Y))), + BrushKind::Art(a) => (art_params(a, §ion)?, Some((a.art.clone(), Affine::IDENTITY))), + BrushKind::Pattern(_) | BrushKind::Bristle(_) => return None, + }; + let read_art = match &art { + Some((a, to_section)) => { + if !plain_art(a) { + return None; + } + let mut read = a.clone(); + if *to_section == Affine::IDENTITY { + read.transform(Affine::FLIP_Y, false); + } + as_read(&mut read); + Some(read) + } + None => None, + }; + let read = crate::import::read_brush_object(plugin, &b.name, ¶ms, read_art.as_ref())?; + if !same_settings(&read.brush, b) { + return None; + } + if art.is_some() { + self.brush_set.sections += 1; + } + let section = art.map(|(a, to_section)| (section, a, to_section)); + Some(BrushOut { brush: b.clone(), plugin, params, art: read_art, section, live: read.live }) + } + + /// A brush's art section `name`: `art` written with `to_section` taking the document's space to + /// the section's (y up), from the state a definition starts in. Its objects are counted as the + /// reader counts them ([`Self::take_objects`]: the group, the frame and the art). `None` when + /// writing it warns: what it took is given back, and when it didn't fit within the object limit, + /// the limit's warning is kept. + fn brush_art_section(&mut self, name: &str, art: &Node, to_section: Affine) -> Option { + let before = self.charges(); + // The art's own warnings, apart from those the document already has (a warning is kept once). + let warnings = std::mem::take(&mut self.warnings); + let saved = ( + std::mem::take(&mut self.body), + (self.to_art, self.grad_space, self.canvas), + self.state.take(), + self.gs.replace(Xy::OPAQUE), + self.clip.take(), + (self.block_start, self.block_end), + // The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that + // first uses the brush. + (std::mem::take(&mut self.hidden), std::mem::take(&mut self.in_look), std::mem::take(&mut self.in_symbol)), + ); + (self.to_art, self.grad_space, self.canvas, self.block_end) = (to_section, to_section, to_section, usize::MAX); + self.state(false, false); + // The art in a group after its frame, an unpainted rectangle around it. Art without width or + // height (a straight line) is written alone: the reader keeps a frame of no area as art. + let frame = art + .geometric_bounds() + .filter(|b| b.width() > 0.0 && b.height() > 0.0) + .and_then(|b| vectorcraft_geom::shapes::rectangle(b).subpaths.first().cloned()); + match frame { + Some(frame) if self.take_objects(2) => { + self.line("u\n0 XR"); + ai_ops::subpath(&mut self.body, to_section, &frame); + self.line("n"); + self.nest += 1; + self.node(art); + self.nest -= 1; + self.line("U"); + } + Some(_) => {} + None => { + self.nest += 1; + self.node(art); + self.nest -= 1; + } + } + let body; + ( + body, + (self.to_art, self.grad_space, self.canvas), + self.state, + self.gs, + self.clip, + (self.block_start, self.block_end), + (self.hidden, self.in_look, self.in_symbol), + ) = saved; + let text = std::mem::replace(&mut self.body, body); + if !std::mem::replace(&mut self.warnings, warnings).is_empty() { + self.give_back(before, &text); + if self.left_out_since(before) { + self.warn(super::OBJECT_LIMIT); + } + return None; + } + self.keep_since(before); + Some(format!("%AI8_BeginBrushPattern\n{}\n{text}%AI8_EndBrushPattern\n", ps::bytes(name.as_bytes()))) + } + + /// The written brush named `name` that a path can be written with live. + pub(super) fn live_brush(&self, name: &str) -> Option<&BrushOut> { + self.brush_set.written.iter().find(|b| b.live && b.brush.name == name) + } + + /// The basic filter of stroke `st` with the brush it names: the brush's plug-in as the filter, + /// its settings for the object, the stroke's style and the brush by name. `None` when the brush + /// isn't written live. + pub(super) fn brush_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + let name = st.brush.as_deref()?; + self.brush_set(); + let (plugin, kind) = self.live_brush(name).map(|b| (b.plugin, b.brush.kind.clone()))?; + if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { + return None; + } + let ops = self.color_ops(&st.paint, true); + let mut d = String::new(); + let title = match &kind { + BrushKind::Calligraphic(c) => { + let modes = c.modes().map(code); + for (key, value, variation, mode) in [ + ("Diameter", c.size, c.variation[2], modes[2]), + ("Roundness", c.roundness, c.variation[1], modes[1]), + ("Angle", c.angle, c.variation[0], modes[0]), + ] { + let _ = writeln!(d, "{} /Real ({key}) ,", ps::num(value)); + let _ = writeln!(d, "{} /Real ({key}Variation) ,", ps::num(variation)); + let _ = writeln!(d, "{} /Int ({key}DependsOn) ,", mode as i64); + } + d.push_str("0 /Int (AngleRelativeTo) ,\n"); + "(Calligraphic Brush) /Title ,\n" + } + BrushKind::Art(_) => { + d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files + for (key, value, kind) in [ + ("StartSegmentLength", "0", "Real"), + ("EndSegmentLength", "0", "Real"), + ("Counterclockwise", "0", "Int"), + ("MinScaling", "1", "Real"), + ("MaxScaling", "1", "Real"), + ("Colorization", "1", "Int"), + ("Spacing", "0", "Real"), + ("Adjustment", "1", "Int"), + ("Proportional", "0", "Bool"), + ("Segmented", "0", "Bool"), + ("UpsideDown", "0", "Int"), + ("FoldAdjustment", "1", "Bool"), + ("ScaleYDependsOn", "0", "Int"), + ("Scaling", "1", "Real"), + ] { + let _ = writeln!(d, "{value} /{kind} ({key}) ,"); + } + "(Art Brush) /Title ,\n" + } + _ => return None, + }; + super::styles::basic(s, plugin, true, visible); + s.push_str(title); + s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n"); + s.push_str(&d); + s.push_str("/StrokeStyle : 0 R\n"); + s.push_str(&ops); + s.push_str("0 1 0 0 0 Xy\n"); + ai_ops::line_style(s, st); + s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n"); + let quoted = ps::bytes(name.as_bytes()); + let _ = + write!(s, "/GObjRef : {quoted} {} /PluginObject ; (Brush) ,\n{quoted} /UnicodeString (DisplayString) ,\n", ps::bytes(plugin.as_bytes())); + s.push_str(super::styles::END_BASIC); + Some(()) + } + + /// Does the reader read the brushed strokes of style text `text` with their brushes on them? + pub(super) fn brushed_style_reads(&mut self, text: &str) -> bool { + self.brush_set(); + let section = super::styles::known_style("x", text); + let written = &self.brush_set.written; + let read = self.brush_set.read.get_or_insert_with(|| { + let brushes: Vec<(&str, &str, &str, Option<&Node>)> = + written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect(); + crate::import::BrushesRead::new(&brushes) + }); + read.style_reads(section.as_bytes(), "x") + } + + /// The brushes' art in a non-printing block, then the brush manager's order and the brushes in + /// two more, when the document has brushes of its own or an object is written with a brush on + /// its stroke (a file without brushes opens with a new document's). Warns when a brush is left + /// out. + pub(in crate::ai_editing) fn brush_blocks(&mut self) -> (String, String) { + let stored = self.doc.unknown.contains_key(vectorcraft_brush::DOC_KEY); + if !stored && self.brush_set.live_written == 0 { + return (String::new(), String::new()); + } + self.brush_set(); + // The art is written now that the file holds it, after the layers' objects. A brush whose art + // doesn't fit is left out; a path written with it on its stroke opens as its drawn look. + let mut sections = String::new(); + for mut b in std::mem::take(&mut self.brush_set.written) { + if let Some((name, art, to_section)) = b.section.take() { + let Some(text) = self.brush_art_section(&name, &art, to_section) else { + self.brush_set.left_out = true; + continue; + }; + sections.push_str(&text); + } + self.brush_set.written.push(b); + } + if self.brush_set.left_out { + self.warn(BRUSHES); + } + // On a large canvas an empty panel is still written: the manager alone, with no art to scale. + let empty = self.brushes.as_ref().is_some_and(|l| l.is_empty()); + if !self.brushes_live && !(stored && empty) { + return (String::new(), String::new()); + } + let art = if sections.is_empty() { String::new() } else { format!("%AI5_Begin_NonPrinting\nNp\n{sections}%AI5_End_NonPrinting--\n") }; + let manager = crate::import::BRUSH_MANAGER; + let order: String = self.brush_set.written.iter().map(|b| format!(" {}/ {}/", b.plugin, b.brush.name)).collect(); + let mut objects = format!("%AI5_Begin_NonPrinting\nNp\n{}%AI5_End_NonPrinting--\n", plugin_object(manager, manager, &order)); + if !self.brush_set.written.is_empty() { + objects.push_str("%AI5_Begin_NonPrinting\nNp\n"); + for b in &self.brush_set.written { + objects.push_str(&plugin_object(b.plugin, &b.brush.name, &b.params)); + } + objects.push_str("%AI5_End_NonPrinting--\n"); + } + (art, objects) + } +} diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 0dbe24c937..0a7220faa5 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -11,7 +11,7 @@ use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; use super::{MAX_NEST, Writer, Xy}; -use crate::ai_ops::{average, unit}; +use crate::ai_ops::{self, average, unit}; use crate::ps; pub(in crate::ai_editing) const GRADIENTS: &str = @@ -47,6 +47,7 @@ pub(super) struct PatternOut { pub(in crate::ai_editing) const DIAMOND: &str = "diamond gradients are written as radial gradients through their corners, with the same stops, in the editing data for other apps"; +pub(in crate::ai_editing) const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (at most 256 stops, padding past them) in the editing data for other apps"; /// Can `g` be written as a gradient (a linear, radial, conic or diamond one with stops, no more /// than the `.ai` reader reads; a conic is written linear, [`CONIC`], and a diamond radial, @@ -85,11 +86,13 @@ 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. if let Paint::Gradient(g) = &sw.paint - && writable_gradient(&g.gradient) + && let gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS) + && writable_gradient(&gradient) && !self.gradients.iter().any(|d| d.name == sw.name) { - self.gradients.push(GradientDef { name: sw.name.clone(), gradient: g.gradient.clone(), swatch: true, used: false }); + self.gradients.push(GradientDef { name: sw.name.clone(), gradient: gradient.into_owned(), swatch: true, used: false }); } } } @@ -135,8 +138,27 @@ impl Writer<'_> { /// placed there (it then no longer depends on the shape it paints, which may be cut); one the /// data can't hold is its average color, with a warning. pub(super) fn placed(&mut self, p: &Paint, bounds: Rect) -> Paint { + self.placed_over(p, bounds, bounds) + } + + /// [`Self::placed`] for paint that reaches `area`: a gradient that repeats or reflects is + /// written with its stops repeated over `area`. + fn placed_over(&mut self, p: &Paint, bounds: Rect, area: Rect) -> Paint { let Paint::Gradient(g) = p else { return p.clone() }; - let geom = g.resolve(bounds); + let mut geom = g.resolve(bounds); + // 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.is_spread() { + self.warn(SPREAD); + match g.gradient.padded_within(&geom, area, crate::import::MAX_GRADIENT_STOPS) { + Some(padded) => { + g.gradient = padded.gradient; + geom = padded.geom; + } + None => g.gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS).into_owned(), + } + } let finite = [geom.start.x, geom.start.y, geom.end.x, geom.end.y, geom.aspect].iter().all(|v| v.is_finite()); let radial = written_radial(g.gradient.kind); if g.gradient.kind == GradientKind::Freeform { @@ -147,13 +169,16 @@ impl Writer<'_> { self.warn(GRADIENT_SHAPES); return Paint::solid(average(&g.gradient)); } - Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() })) + Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..g })) } - /// The fills and strokes of `n` that paint (outline `bounds`), their gradients placed - /// ([`Self::placed`]): a fill's on the shape's bounds, a stroke's on what it paints. A gradient - /// along or across a stroke is its average color (the stroke is written as its outline). - pub(super) fn items(&mut self, n: &vectorcraft_doc::Node, bounds: Rect) -> Vec { + /// The fills and strokes of `n` that paint (outline `bp`), their gradients placed + /// ([`Self::placed`]): a fill's on the shape's bounds, a stroke's on what it paints, the stops + /// of one that repeats or reflects repeated over the box the stroke paints, miter spikes + /// included ([`StrokeLayer::bounds_on`]). A gradient along or across a stroke is its average + /// color (the stroke is written as its outline). + pub(super) fn items(&mut self, n: &vectorcraft_doc::Node, bp: &BezPath) -> Vec { + let bounds = bp.bounding_box(); super::visible_items(n) .into_iter() .map(|i| match i { @@ -164,7 +189,11 @@ impl Writer<'_> { self.warn(GRADIENTS); Paint::solid(average(&g.gradient)) } - None => self.placed(&s.paint, s.paint_bounds(bounds)), + None => { + let spread = matches!(&s.paint, Paint::Gradient(g) if g.gradient.is_spread()); + let reach = if spread { s.bounds_on(bp, bounds) } else { bounds }; + self.placed_over(&s.paint, s.paint_bounds(bounds), reach) + } }; AppearanceItem::Stroke(StrokeLayer { paint, ..s.clone() }) } @@ -342,7 +371,11 @@ impl Writer<'_> { /// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB) /// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent, /// then its RGB values when it isn't CMYK, its name and how much lighter than the full color, - /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). + /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). RGB values are an RGB color's own (in the + /// working RGB space) and a Lab color's display RGB ([`ai_ops::rgb_values`]). An RGB color's CMYK + /// equivalent is converted from its values as written ([`ai_ops::as_written`]), a Lab color's + /// from its Lab values, and a spot color's CMYK equivalent and RGB values both come from its full + /// color as the palette writes it. fn stop_color(&mut self, s: &mut String, stop: &GradientStop) { let doc = self.doc; let intent = self.cms.settings().intent; @@ -351,11 +384,12 @@ impl Writer<'_> { && let Paint::Solid { color: full, swatch: None, .. } = sw.paint { if sw.spot { + let full = ai_ops::as_written(full); let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let rgb = !matches!(full, Color::Cmyk { .. }); if rgb { - ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(full).map(f64::from)); } s.push_str(&ps::bytes(name.as_bytes())); s.push(' '); @@ -377,9 +411,10 @@ impl Writer<'_> { s.push_str("1 "); } color @ (Color::Rgb { .. } | Color::Lab { .. }) => { - let cmyk = self.cms.to_cmyk(&color, intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); s.push_str("2 "); } } @@ -521,10 +556,19 @@ impl Writer<'_> { /// `(name) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p`. False for other paint, and for paint that can't /// 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)), + _ => None, + }; match &sw.paint { - Paint::Gradient(g) if writable_gradient(&g.gradient) => { + Paint::Gradient(g) if unrolled.as_deref().is_some_and(writable_gradient) => { + if g.gradient.is_spread() { + self.warn(SPREAD); + } let swatch = Some(sw.name.as_str()); - let name = self.define_gradient(&g.gradient, swatch); + let name = self.define_gradient(unrolled.as_deref().unwrap_or(&g.gradient), swatch); s.push_str("Bb\n"); if written_radial(g.gradient.kind) { s.push_str("0 0 0 0 Bh\n"); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index c28c538bcf..29eb2bf910 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -40,8 +40,9 @@ 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 or linked to a graphic -/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency +/// 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 +/// 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. pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { @@ -76,7 +77,8 @@ fn bakes_members(n: &Node) -> bool { /// Whether `n` may be written with an art style ([`styled_objects`]). fn candidate(n: &Node) -> bool { - let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some(); + let brushed = brushed(n) && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some() || brushed; if !styled || n.knockout_shape { return false; } @@ -88,6 +90,12 @@ fn candidate(n: &Node) -> bool { } } +/// 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 { + n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) +} + /// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? fn has_points(path: &vectorcraft_geom::PathData) -> bool { path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) @@ -144,7 +152,7 @@ impl Styles { } /// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. -fn known_style(name: &str, text: &str) -> String { +pub(super) fn known_style(name: &str, text: &str) -> String { format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) } @@ -172,7 +180,7 @@ const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; /// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the /// style's own filters), `/Visible` and the dictionary's start. -fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { +pub(super) fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); } @@ -180,7 +188,7 @@ fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; /// A basic filter's end. -const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; +pub(super) const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; /// The blend filter of transparency `xy`. fn blend_filter(s: &mut String, xy: Xy) { @@ -228,7 +236,7 @@ fn flag(p: &Value, key: &str) -> Option { impl Writer<'_> { /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. - fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + pub(super) fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { let saved = std::mem::take(&mut self.body); self.color(paint, stroke); std::mem::replace(&mut self.body, saved) @@ -267,9 +275,12 @@ impl Writer<'_> { } /// A stroke's basic filter: a color, its line style, and its width profile as the arrays - /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for - /// other paint. + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`; a stroke with a + /// brush, the brush's filter ([`Self::brush_filter`]). `None` for other paint. fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if st.brush.is_some() { + return self.brush_filter(s, st, visible); + } if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { return None; } @@ -652,17 +663,19 @@ impl Writer<'_> { && let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone()) && object_reads(orig, &text) { - self.styles.links_written += 1; return Some(name); } - if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) { + let brushed = brushed(orig); + if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) { return None; } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); let path = drawn_outline(n, orig); let rule = object_rule(orig); - let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let text = self + .style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))) + .filter(|t| object_reads(orig, t) && (!brushed || self.brushed_style_reads(t))); let Some(text) = text else { if let Some(mark) = saved.0 { self.gradient_rollback(mark); @@ -693,7 +706,8 @@ impl Writer<'_> { /// 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`. /// The look's group and the object itself are counted first ([`Self::take_objects`]): without - /// room for them, nothing is written. + /// 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. pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { let own = match &orig.kind { NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), @@ -704,6 +718,12 @@ impl Writer<'_> { if !self.take_objects(1 + own) { return; } + let linked = orig.extras().graphic_style.and_then(|id| self.styles.panel.get(&id)).is_some_and(|p| p == name); + if linked { + self.styles.links_written += 1; + } else if brushed(orig) { + self.brush_set.live_written += 1; + } self.state(orig.locked, !orig.visible); let xy = Xy::of(orig); let was = self.in_look; @@ -834,8 +854,12 @@ fn own_style_holds(ap: &Appearance) -> bool { ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only) } -/// Does the reader read graphic style `g` from style text `text` as it is? +/// Does the reader read graphic style `g` from style text `text` as it is? It leaves out a graphic +/// style with a brush on a stroke that paints. fn panel_reads(g: &GraphicStyle, text: &str) -> bool { + if g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) { + return false; + } let section = known_style("x", text); let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false }; // The opacity within the written number's precision. @@ -918,7 +942,7 @@ fn normalized_params(e: &Effect) -> Value { const NEAR: f64 = 6e-3; /// JSON values equal but for numbers within the written numbers' precision. -fn near(a: &Value, b: &Value) -> bool { +pub(super) fn near(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { (Some(x), Some(y)) => (x - y).abs() <= NEAR, diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1d791afadf..1e47ca1564 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -387,6 +387,7 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { } let hangul = text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); + let pairs = text.chars().zip(text.chars().skip(1)).any(|(a, b)| vectorcraft_text::inseparable(a, b)); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -403,6 +404,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // The text document has no Korean Line Break Anywhere: it reads back off, which // changes only the lines of a paragraph with Hangul in it. korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, + // Nor Bunri-Kinshi: it reads back off, which changes only the lines of a paragraph + // with an inseparable pair in it. + bunri_kinshi: p.bunri_kinshi && !pairs, ..ParaStyle::default() }; *p != held diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 7707f4bc45..3b2bdd88d8 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -103,6 +103,27 @@ pub(crate) fn unit(v: f32) -> f32 { if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } } +/// `color` as a reader reads it back from the values written for it ([`ps::written`]): RGB values +/// clamped to 0..1, L to 0..100 and a and b to -128..127, each to four decimals; other colors +/// unchanged. +pub(crate) fn as_written(color: Color) -> Color { + let w = |v: f32| ps::written(f64::from(v)) as f32; + match color { + Color::Rgb { r, g, b } => Color::rgb(w(unit(r)), w(unit(g)), w(unit(b))), + Color::Lab { l, a, b } => Color::lab(w(l.clamp(0.0, 100.0)), w(a.clamp(-128.0, 127.0)), w(b.clamp(-128.0, 127.0))), + c => c, + } +} + +/// The RGB values written for `color`, clamped to 0..1: an RGB color's own values (in the working +/// RGB space, as a reader reads them back), any other color's display RGB. +pub(crate) fn rgb_values(color: Color) -> [f32; 3] { + match color { + Color::Rgb { r, g, b } => [r, g, b].map(unit), + c => c.to_rgb().map(unit), + } +} + /// The average colour of a gradient's stops (in RGB). pub(crate) fn average(g: &Gradient) -> Color { let n = g.stops.len().max(1) as f32; diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index dc335bf1a1..b750efbc20 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -86,8 +86,8 @@ pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; pub(crate) use ai::{ - MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, - symbol_art_member, warp_number, + BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, + read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index be2d679a78..2d6c4a533d 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -34,6 +34,7 @@ use std::io::Read as _; use super::lex::find; use crate::ps; +pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object}; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; #[cfg(test)] pub(crate) use envelope::MAX_CHECKED; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index dc94ff7927..5a5db47114 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -563,3 +563,127 @@ pub(super) fn without_frame(art: Node) -> Node { } out } + +/// The plug-in name the objects of brushes of kind `kind` have, for writers. +pub(crate) fn plugin_name(kind: &BrushKind) -> &'static str { + let tool = match kind { + BrushKind::Calligraphic(_) => Tool::Calligraphic, + BrushKind::Scatter(_) => Tool::Scatter, + BrushKind::Art(_) => Tool::Art, + BrushKind::Pattern(_) => Tool::Pattern, + BrushKind::Bristle(_) => Tool::Bristle, + }; + TOOLS.iter().find(|(_, t)| *t == tool).map_or("", |(n, _)| *n) +} + +/// The plug-in name of the brush manager's order, for writers. +pub(crate) const MANAGER_NAME: &str = MANAGER; + +/// A brush object as the reader reads it, for writers that check a brush before they write it. +#[derive(Clone, Debug)] +pub(crate) struct BrushRead { + /// The brush the Brushes panel gets. + pub brush: Brush, + /// A path whose art style names the brush, at the brush's own settings, opens with the brush on + /// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file + /// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the + /// path, unflipped, at 100% in Tints. + pub live: bool, +} + +/// The brush named `name` whose object has the plug-in name `plugin` and the parameters `params`, +/// with `art` as the art its parameters name (as the reader reads that art), as the reader reads +/// it: `None` when the reader doesn't make a brush of it. +pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Option<&Node>) -> Option { + let draft = parse_tool_object(tool(plugin)?, name, params).ok()?; + let mut sections = HashMap::new(); + if let (DraftKind::Scatter { art: section, .. } | DraftKind::Art { art: section, .. }, Some(a)) = (&draft.kind, art) { + sections.insert(section.clone(), a.clone()); + } + let drafts = [draft]; + let live = kept(&drafts, §ions).first().copied().unwrap_or(false) + && match &drafts[0].kind { + DraftKind::Calligraphic(_) => true, + DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, §ions).contains(section), + _ => false, + }; + let [draft] = drafts; + let (mut brushes, _) = finish_brushes(vec![draft], §ions, None); + Some(BrushRead { brush: brushes.pop()?, live }) +} + +/// Which of the brush objects `brushes` (plug-in name, name, parameters, the art each names as the +/// reader reads it) the reader makes into brushes when one file holds them all: not one it can't read, +/// nor one past the art the reader copies into one file's brushes ([`kept`]). +pub(crate) fn brushes_kept(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Vec { + let mut drafts = vec![]; + let mut at = vec![]; + let mut sections = HashMap::new(); + for (i, (plugin, name, params, art)) in brushes.iter().enumerate() { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + at.push(i); + } + let mut out = vec![false; brushes.len()]; + for (i, k) in at.into_iter().zip(kept(&drafts, §ions)) { + if let Some(slot) = out.get_mut(i) { + *slot = k; + } + } + out +} + +/// Brush objects a writer writes, as the reader reads them together from one file, for checking +/// the art styles that name them ([`Self::style_reads`]). +pub(crate) struct BrushesRead { + drafts: Vec, + /// The brush a stroke that names a brush is drawn with, by name: the first one kept + /// ([`kept`]), as an index in [`Self::drafts`]. + by_name: HashMap, + /// The art an Art brush draws as the file does ([`bending_art`]). + bending: HashSet, +} + +impl BrushesRead { + /// `brushes`: the brush objects written (plug-in name, name, parameters) and the art each names, + /// as the reader reads it. + pub(crate) fn new(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Self { + let mut drafts = vec![]; + let mut sections = HashMap::new(); + for (plugin, name, params, art) in brushes { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + } + let mut by_name = HashMap::new(); + for (i, (d, k)) in drafts.iter().zip(kept(&drafts, §ions)).enumerate() { + if k { + by_name.entry(d.name.clone()).or_insert(i); + } + } + let bending = bending_art(&drafts, §ions); + Self { drafts, by_name, bending } + } + + /// Does the reader read every stroke of art style `style` in `section` (the text between + /// `%AI9_BeginArtStyles` and its end) that names a brush with that brush on it, at the brush's + /// own settings? A style the reader doesn't read, or that names a brush no object gives, or an + /// Art brush in a style with effects, isn't read so. + pub(crate) fn style_reads(&self, section: &[u8], style: &str) -> bool { + use super::style::StyleItem; + let styles = super::style::art_styles(section); + let Some(st) = styles.get(style).filter(|st| st.plain) else { return false }; + st.items.iter().all(|item| match item { + StyleItem::Stroke { brush: Some((name, settings)), .. } => { + let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false }; + draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty()) + } + _ => true, + }) + } +} diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5a253f21a3..3721ce7960 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -205,6 +205,10 @@ pub struct Structure { pub warped_type: super::envelope::WarpedType, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The patterns that paint the text document's characters, placed in the document, by the + /// name of the art style that gives each: the styles that are one paint, a pattern this read + /// (see `ate::Texts::with_patterns`). + pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ad7f266036..ca943e8817 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1335,6 +1335,17 @@ impl<'a> Reader<'a> { doc.limit_depth_noting(&mut warnings); } warnings.extend(self.names.notes()); + // The patterns of the styles that are one paint, which the text document's characters + // name for their fills and strokes: those that place in the document. + let text_patterns = self + .styles + .iter() + .filter_map(|(style, st)| { + let Some(crate::import::ai::style::StyleItem::Pattern { name, matrix, controls }) = &st.simple_pattern else { return None }; + let paint = super::ops::style_pattern(name, *matrix, controls, &self.pattern_tiles, &self.v7_patterns, &self.global_names, to_doc)?; + Some((style.clone(), paint)) + }) + .collect(); Ok(Structure { doc, to_doc, @@ -1358,6 +1369,7 @@ impl<'a> Reader<'a> { page, }, styles_read, + text_patterns, canvas_scale: self.canvas_scale, xml_ids: self.xml_ids, bindings, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c0d9852d88..3a93af7419 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -943,6 +943,29 @@ fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) } +/// A style's pattern (`StyleItem::Pattern`): the file's pattern `name` placed by `matrix` (its +/// space → art space; `controls` apply first for a pattern defined in the version 7 form, `v7` +/// those patterns' names) and carried into the document by `space`, with the name `names` give +/// it there. `None` when the pattern wasn't read (`tiles`: the tiles of those that were, by their +/// names in the file) or doesn't place. +pub(super) fn style_pattern( + name: &str, + matrix: Option<[f64; 6]>, + controls: &[f64], + tiles: &HashMap, + v7: &HashSet, + names: &ShownNames, + space: Affine, +) -> Option { + let tile = tiles.get(name).copied()?; + let mut matrix = matrix.and_then(|m| paint::affine(&m)); + if v7.contains(name) { + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); + } + let xf = pattern_xf(space, matrix, tile)?; + Some(Paint::Pattern { pattern: names.get(Global::Pattern, name).to_string(), xf }) +} + /// A pattern fill's `x y scaleX scaleY angle reflect axis shear shearAxis` as one transform of the /// pattern's space, applied before its initial matrix: the shear (by `shear` degrees along the line /// at `shearAxis` degrees), the reflection about the line at `axis` degrees when `reflect` isn't @@ -1147,15 +1170,15 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p } StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{}", paints.names.get(Global::Pattern, name))) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), - None => { - let tile = paints.patterns.get(name).copied()?; - let mut matrix = matrix.and_then(|m| paint::affine(&m)); - if paints.v7_patterns.contains(name) { - matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); - } - let xf = pattern_xf(paints.space, matrix, tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf })) - } + None => AppearanceItem::Fill(FillLayer::new(style_pattern( + name, + *matrix, + controls, + paints.patterns, + paints.v7_patterns, + paints.names, + paints.space, + )?)), }, StyleItem::Stroke { brush, .. } => { // A brushed stroke draws as its look only with the brush the Brushes panel keeps @@ -1237,8 +1260,11 @@ pub(crate) fn read_style_list(section: &[u8]) -> Vec { /// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], /// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads -/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read -/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own. +/// it on objects other than type. A stroke that names a brush is read with that brush's name, as +/// [`styled`] reads it on a path or compound path when the Brushes panel keeps that brush at those +/// settings, which [`crate::import::ai::brush::BrushesRead::style_reads`] checks. `None` when the section +/// doesn't define it, the reader doesn't read it, or it paints with a pattern or a gradient that +/// isn't the object's own. pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option { use crate::import::ai::style::StyleItem; let mut st = crate::import::ai::style::art_styles(section).remove(name)?; @@ -1250,7 +1276,21 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P } } } - let appearance = appearance_from_art_style(&st)?; + // The brush names, by item; `appearance_from_art_style` reads a style's items in order. + let brushes: Vec> = st + .items + .iter_mut() + .map(|item| match item { + StyleItem::Stroke { brush, .. } => brush.take().map(|(name, _)| name), + _ => None, + }) + .collect(); + let mut appearance = appearance_from_art_style(&st)?; + for (item, brush) in appearance.items.iter_mut().zip(brushes) { + if let (AppearanceItem::Stroke(s), Some(brush)) = (item, brush) { + s.brush = Some(brush); + } + } Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) } diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index ef661c1446..6a75837733 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -202,6 +202,10 @@ pub(super) struct ArtStyle { pub transparency: Option<(f32, BlendMode, bool, Knockout)>, /// [`Self::effects`] holds Effect › Pathfinder effects, which only a group is read with. pub pathfinder: bool, + /// A style that is one paint (`/SimpleStyle :`, its paint's operators, `/Paint ;`), when that + /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such + /// styles for their fills and strokes (`ate`). `None` for any other paint or style. + pub simple_pattern: Option, } impl ArtStyle { @@ -406,9 +410,14 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap = vec![]; let mut pending: Option = None; let mut in_basic = false; - let mut finish = |name: Option, filters: &mut Vec| { + // A style that is one paint being read, and its pattern so far. + let mut simple: Option> = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { if let Some(name) = name { - styles.insert(name, style(filters)); + // Only a style that is one paint holds a pattern for type: one with filters as well + // paints with more than that. + let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); + styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); } filters.clear(); }; @@ -445,11 +454,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters); + finish(name.take(), &mut filters, simple.take().flatten()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } + b"SimpleStyle" if filters.is_empty() => simple = Some(None), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -549,9 +559,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap { - let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)); - let matrix = vals.iter().rev().find_map(|v| match v { - V::Arr(a) => { - let n: Vec = a.iter().filter_map(V::num).collect(); - <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) - } - _ => None, - }); - let controls: Vec = - vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); - if let Some(name) = name { - f.pattern = Some((name, matrix, controls)); + if let Some(pattern) = pattern_operands(vals, names) { + f.pattern = Some(pattern); } } _ => {} } } +/// The operands of `(name) … [matrix] p`: the pattern's name read by `names`, its matrix (six +/// finite numbers) and the numbers between the name and the matrix. `None` without a name. +fn pattern_operands(vals: &[V], names: &mut Names) -> Option<(String, Option<[f64; 6]>, Vec)> { + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))?; + let matrix = vals.iter().rev().find_map(|v| match v { + V::Arr(a) => { + let n: Vec = a.iter().filter_map(V::num).collect(); + <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) + } + _ => None, + }); + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); + Some((name, matrix, controls)) +} + +/// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style +/// that is one paint (`simple`, while one is read). +fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { + if let Some(f) = filters.last_mut() { + paint_op(f, op, vals, names); + } else if let Some(paint) = simple.as_mut() { + simple_paint_op(paint, op, vals, names); + } +} + +/// A paint operator of a style that is one paint: a pattern (`p`) is kept as its +/// [`StyleItem::Pattern`], and any other paint after it (a colour, a stroke's pattern or a +/// gradient) leaves the style without one. +fn simple_paint_op(paint: &mut Option, op: &str, vals: &[V], names: &mut Names) { + match op { + "p" => *paint = pattern_operands(vals, names).map(|(name, matrix, controls)| StyleItem::Pattern { name, matrix, controls }), + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs" + | "xz" | "P" | "Bg" => *paint = None, + _ => {} + } +} + /// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the /// path, the same both sides unless it gives each side's. fn width_profile(arrays: &HashMap>) -> Option { @@ -792,6 +828,8 @@ const ANYTHING: Under = Under { strokes: true, patterns: true }; /// The Transform effect `f` (the factor and radian entries; the percent and degree ones are what /// its dialog shows). `None` keeps the drawn look: an entry not read, Random (the file holds no /// seed), or a missing Scale Strokes & Effects or Transform Patterns that would change `under`. +/// The keys are LiveEffect XML's +/// (https://community.adobe.com/questions-652/how-to-define-values-in-action-menu-command-816878). fn transform(f: &Filter, under: Under) -> Option { for key in ["scaleH_Percent", "scaleV_Percent", "rotate_Degrees"] { entry(f, key, Kind::Real).ok()?; @@ -841,6 +879,9 @@ fn transform(f: &Filter, under: Under) -> Option { /// corners are measured in: the distorted box is laid over the object's own bounds, as the catalog /// effect lays it, also when the frame is another size (a unit square, or bounds with the stroke). /// No corners is no change; some but not all, or a frame that isn't an upright rectangle, isn't read. +/// The keys are LiveEffect XML's (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450), +/// with the corners in that order +/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js). fn free_distort(f: &Filter) -> Option { let mut corners = [[[0.0; 2]; 4]; 2]; let mut held = 0; @@ -895,8 +936,10 @@ fn points(f: &Filter) -> Option<&'static str> { Some(if switch(f, "roundness", Kind::Real).ok()?.unwrap_or(false) { "smooth" } else { "corner" }) } -/// The Tweak effect `f` (`Adobe Scribble and Tweak`): relative unless `absoluteness` says otherwise, -/// moving anchors and both handles unless the dictionary turns them off. +/// The Tweak effect `f` (`TWEAK`): relative unless `absoluteness` says otherwise, +/// moving anchors and both handles unless the dictionary turns them off. The keys are LiveEffect +/// XML's: `horz` and `vert` relative, `ahor` and `aver` absolute, and `anch`, `in` and `out` +/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js). fn tweak(f: &Filter) -> Option { let (absolute, h) = sized(f, false, "horz", "ahor")?; let (_, v) = sized(f, false, "vert", "aver")?; @@ -906,7 +949,9 @@ fn tweak(f: &Filter) -> Option { } /// The Zig Zag effect `f`: absolute unless `absoluteness` says otherwise, 4 ridges and Corner points -/// when they are missing. +/// when they are missing. The keys are LiveEffect XML's: `amount` absolute, `relAmount` relative, +/// `ridges` and `roundness` (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450, +/// https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js). fn zig_zag(f: &Filter) -> Option { let (absolute, size) = sized(f, true, "relAmount", "amount")?; let ridges = real(f, "ridges", 4.0, 0.0..=100.0).filter(|r| r.fract() == 0.0)?; @@ -1167,6 +1212,8 @@ fn effect(f: &Filter, under: Under) -> Option { TRANSFORM => ("distort.transform", transform(f, under)?), FREE_DISTORT => ("distort.freeDistort", free_distort(f)?), // Negative puckers (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). + // Pucker & Bloat's `d_factor` and Twist's `angle` are LiveEffect XML's keys + // (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450). PUCKER_BLOAT => ("distort.puckerBloat", json!({"amount": real(f, "d_factor", 0.0, -200.0..=200.0)?})), TWEAK => ("distort.tweak", tweak(f).filter(still)?), // A positive angle turns clockwise in both. diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 47086ca171..c6debd42f9 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1027,8 +1027,7 @@ fn affine_corners_are_a_parallelogram_however_far_they_reach() { #[test] fn roughen_keeps_the_drawn_look() { - // Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the - // path's segments. + // Its outline is random and the file holds no seed. assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None); assert_eq!(read_effect("Adobe Roughen", ""), None); } @@ -1498,3 +1497,18 @@ fn art_styles_read_registration_and_named_colors() { let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") }; assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); } + +/// Only a style that is one paint holds a pattern for type: the same pattern paint in a style that +/// also has filters gives none, whichever comes first. +#[test] +fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { + let simple = "/SimpleStyle :\n0 O\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n"; + let active = "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n1 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 0 0 0 k\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = |body: &str| art_styles_in(format!("/KnownStyle :\n(S) /Name ,\n{body} /Def ;\n").as_bytes(), &mut Names::default()); + assert!(styles(simple).get("S").is_some_and(|s| s.simple_pattern.is_some())); + for body in [format!("{simple}{active}"), format!("{active}{simple}")] { + assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); + } +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 194c0ff1e2..efaed14199 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -21,7 +21,7 @@ //! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without //! a layout has its point type's anchor at its frame's point. See [`Story::place`]. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; @@ -461,6 +461,11 @@ struct Style { v_scale: f64, fill: Option, stroke: Option, + /// The pattern that paints the fill or the stroke in place of its colour: the one the art + /// style named by key 5 of its paint gives ([`Texts::with_patterns`]), placed in the document. + /// `None` for any other paint, which keeps its colour. + fill_pattern: Option, + stroke_pattern: Option, stroke_width: f64, /// Key 61, the stroke's cap: 0 butt, 1 round, 2 projecting (square). stroke_cap: LineCap, @@ -491,9 +496,23 @@ pub(super) struct Texts { /// attributes (key 5) and the paragraph style it is based on (key 6, an index into these). para_sheets: Vec, frames: Vec, + /// The patterns that paint characters, by the name of the art style that gives each + /// ([`Self::with_patterns`]). + patterns: HashMap, } impl Texts { + /// The text document with its characters painted by `patterns`: the patterns of the editing + /// data's art styles that paint with one, each placed in the document, by the style's name. + /// A run's fill or stroke (key 53 or 54) is a paint whose key 0 is a colour and whose key 5 + /// names the art style that paints it. In users' files (used locally, never committed), such a + /// style is one paint: a colour, which key 0 already has, or a pattern, whose key 0 is black + /// (`/0 0 /1 [ 1 0 ]`). The pattern's matrix is in art space, as a path's pattern fill has it: + /// the files' own pages tile the type with the matrix a path's fill would get there. + pub(super) fn with_patterns(self, patterns: HashMap) -> Texts { + Texts { patterns, ..self } + } + /// The text document in the editing copy `data`, if it has one that can be read. pub(super) fn read(data: &[u8]) -> Option { fn find(h: &[u8], n: &[u8], from: usize) -> Option { @@ -540,6 +559,7 @@ impl Texts { char_sheets: sheets("5"), para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), + patterns: HashMap::new(), }) } @@ -747,6 +767,8 @@ impl Texts { let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); + // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). + let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { font, size, @@ -757,6 +779,8 @@ impl Texts { v_scale: num("7").filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0), fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, + fill_pattern: pattern("53"), + stroke_pattern: pattern("54"), stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), // Numbered as PostScript's `setlinecap` and `setlinejoin` operators number them // (PostScript Language Reference, 3rd ed.): in users' files the page strokes type with @@ -1109,6 +1133,10 @@ impl Story { // The text document measures in the art's units too: the document's scale carries its // sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031). let scale = scale * art_scale(to_doc); + // Patterns stay where the document has them, wherever the type's text space is (`xf`'s + // determinant is 1, so it can be inverted; a pattern that still doesn't map to finite + // numbers keeps its colour, see `char_style`). + let to_text = xf.inverse(); let chars: Vec = text.chars().collect(); let mut runs = vec![]; let mut from = 0usize; @@ -1119,13 +1147,13 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun::new(piece, char_style(st, scale))); + runs.push(TextRun::new(piece, char_style(st, scale, to_text))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale))); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, to_text))); } - let first = self.runs.first().map(|r| char_style(&r.1, scale))?; + let first = self.runs.first().map(|r| char_style(&r.1, scale, to_text))?; let mut t = TextObject::point(Point::ZERO, "", first); if !runs.is_empty() { t.runs = runs; @@ -1295,10 +1323,20 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } /// The character style of character attributes `s`: its font from the fonts the app has, its -/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`. -fn char_style(s: &Style, scale: f64) -> CharStyle { +/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed +/// in the type's text space by `to_text` (the document → its text space), where they stay as the +/// document has them. +fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // A fill or stroke that is on takes its pattern in place of its colour. + let paint = |color: Option, pattern: &Option| match (color, pattern) { + // A pattern that can't be placed (the type's text space out of range) keeps the colour. + (Some(_), Some(Paint::Pattern { pattern, xf })) if (to_text * *xf).is_finite() => { + Paint::Pattern { pattern: pattern.clone(), xf: to_text * *xf } + } + (color, _) => color.map_or(Paint::None, Paint::solid), + }; // Type without a stroke keeps the stroke's defaults, as its weight is 0. let (stroke_cap, stroke_join, stroke_miter_limit) = if s.stroke.is_some() { (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) } else { (LineCap::Butt, LineJoin::Miter, 10.0) }; @@ -1314,8 +1352,8 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, v_scale: s.v_scale * 100.0, - fill: s.fill.map_or(Paint::None, Paint::solid), - stroke: s.stroke.map_or(Paint::None, Paint::solid), + fill: paint(s.fill, &s.fill_pattern), + stroke: paint(s.stroke, &s.stroke_pattern), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, stroke_cap, stroke_join, @@ -1350,6 +1388,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. @@ -1373,6 +1412,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; assert_eq!(auto(b""), JustificationOptions::default().auto_leading); @@ -1395,6 +1435,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; @@ -1412,6 +1453,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; assert_eq!(own(b""), d); @@ -1431,6 +1473,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tabs = |own: &[u8], scale: f64| { para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) @@ -1497,6 +1540,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), } .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); @@ -1544,6 +1588,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), + patterns: HashMap::new(), }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); @@ -1561,6 +1606,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Block) /6 << /35 3 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); @@ -1584,6 +1630,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rotated) /6 << /35 2 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman; assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults"); @@ -1614,9 +1661,10 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rounded) /6 << /61 1 /62 1 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let stroke = |run: &[u8]| { - let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0); + let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY); (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) }; assert_eq!(stroke(b"/6 << /61 2 /62 2 /64 2.5 >>"), (LineCap::Square, LineJoin::Bevel, 2.5), "the run's own"); @@ -1638,6 +1686,88 @@ mod tests { assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); } + /// The pattern "Dots", its tile's corner at `at`. + fn dots_at(at: (f64, f64)) -> Paint { + Paint::Pattern { pattern: "Dots".into(), xf: Affine::translate(at) } + } + + /// Texts whose characters may name the art style "Anon 1", which paints with "Dots" at (10, 20) + /// in the document, and "Solid", which isn't a pattern. + fn patterned_texts() -> Texts { + let patterns = HashMap::from([("Anon 1".to_string(), dots_at((10.0, 20.0))), ("Solid".to_string(), Paint::solid(Color::WHITE))]); + Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + patterns: HashMap::new(), + } + .with_patterns(patterns) + } + + /// Key 5 of a run's fill or stroke names the art style that paints it: one that paints with a + /// pattern gives the run that pattern in place of the colour of key 0 (black), placed in the + /// type's text space where the document has it. A fill or stroke that is off stays off, and a + /// key 5 that isn't the name of such a style, or isn't a name at all, keeps the colour. + #[test] + fn a_run_painted_by_a_patterns_art_style_takes_the_pattern() { + // GUARDS: type a pattern paints came in black, the colour key 0 keeps for it. + let texts = patterned_texts(); + let black_paint = Paint::solid(Color::gray(1.0)); + let to_text = Affine::translate((-4.0, -5.0)); + let paints = |run: &str| { + let s = char_style(&texts.style(Some(&read(run.as_bytes()).unwrap())), 1.0, to_text); + (s.fill, s.stroke) + }; + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> {style} >>"); + let fill = |style: &str| paints(&format!("/6 << /53 {} >>", black(style))); + assert_eq!(fill("/5 (Anon 1)"), (dots_at((6.0, 15.0)), Paint::None)); + let stroke = paints(&format!("/6 << /53 {} /57 true /54 {} >>", black(""), black("/5 (Anon 1)"))); + assert_eq!(stroke, (black_paint.clone(), dots_at((6.0, 15.0)))); + // Off. + assert_eq!(paints(&format!("/6 << /56 false /53 {} >>", black("/5 (Anon 1)"))), (Paint::None, Paint::None)); + assert_eq!(paints(&format!("/6 << /57 false /54 {} >>", black("/5 (Anon 1)"))).1, Paint::None); + // A stroke whose colour can't be read paints nothing, pattern or not, as before. + assert_eq!(paints("/6 << /57 true /54 << /0 << /0 9 /1 [ 1.0 ] >> /5 (Anon 1) >> >>").1, Paint::None); + for other in ["", "/5 (Anon 2)", "/5 (Solid)", "/5 ()", "/5 /Anon", "/5 1", "/5 [ (Anon 1) ]", "/5 << /0 (Anon 1) >>", "/5 true"] { + assert_eq!(fill(other), (black_paint.clone(), Paint::None), "{other}"); + } + // A text space the pattern can't be placed in keeps the colour. + let run = read(format!("/6 << /53 {} >>", black("/5 (Anon 1)")).as_bytes()).unwrap(); + for to_text in [Affine::new([1e308, 0.0, 0.0, 1e308, 0.0, 0.0]), Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])] { + assert_eq!(char_style(&texts.style(Some(&run)), 1.0, to_text).fill, black_paint.clone(), "{to_text:?}"); + } + } + + /// A run's pattern stays where the document has it whatever the type's own text space: turned + /// and moved point type, and area type, whose text space is the document's. + #[test] + fn a_runs_pattern_stays_where_the_document_has_it() { + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + for frame in [ + FrameKind::Point([0.0, 1.0, -1.0, 0.0, 30.0, 40.0]), + FrameKind::Point([2.0, 0.0, 0.0, 2.0, -7.0, 3.0]), + FrameKind::Area(area_segments(), AreaFrame::default()), + ] { + let mut st = story("type", &[0.0], &Para::default()); + st.frames = vec![frame.clone()]; + st.runs[0].1.fill_pattern = Some(dots_at((10.0, 20.0))); + st.runs[0].1.stroke = Some(Color::BLACK); + st.runs[0].1.stroke_pattern = Some(dots_at((-3.0, 8.0))); + let node = st.node(NodeId(1), 0, (0.0, 0.0), to_doc).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("not type") }; + for (paint, at) in [(&t.runs[0].style.fill, Point::new(10.0, 20.0)), (&t.runs[0].style.stroke, Point::new(-3.0, 8.0))] { + let Paint::Pattern { pattern, xf } = paint else { panic!("{paint:?}") }; + assert_eq!(pattern, "Dots"); + let corner = t.xf * *xf * Point::ZERO; + assert!((corner - at).hypot() < 1e-9, "{frame:?}: {corner:?}"); + } + } + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. @@ -1651,6 +1781,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); @@ -1698,6 +1829,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0) } @@ -1904,6 +2036,8 @@ mod tests { v_scale: 1.0, fill: Some(Color::BLACK), stroke: None, + fill_pattern: None, + stroke_pattern: None, stroke_width: 1.0, stroke_cap: LineCap::Butt, stroke_join: LineJoin::Miter, @@ -2352,6 +2486,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0).unwrap() } diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index a54c11ae2b..4c00486de9 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -18,7 +18,6 @@ use vectorcraft_geom::BezPath; use super::interp::{Interp, matrix_obj}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; -use super::predefined_cmap::Unicode; /// Most codespace ranges, character and notdef mappings a CMap may hold. const MAX_RANGES: usize = 1 << 16; @@ -211,32 +210,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option { (n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b))) } -/// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's -/// public CMap resources whose name says its codes are Unicode: `Uni` and a character -/// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), -/// then `-H` or `-V` (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: -/// ). -pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> { - let (rest, dir) = name.rsplit_once('-')?; - let vertical = match dir { - "H" => false, - "V" => true, - _ => return None, - }; - let rest = rest.strip_suffix("-HW").unwrap_or(rest); - let (collection, form) = rest.rsplit_once('-')?; - if !(collection.len() > 3 && collection.starts_with("Uni") && !collection.contains('-')) { - return None; - } - let form = match form { - "UCS2" | "UTF16" => Unicode::Utf16, - "UTF8" => Unicode::Utf8, - "UTF32" => Unicode::Utf32, - _ => return None, - }; - Some((form, vertical)) -} - /// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for /// `Identity-V`. pub(super) fn identity(name: &str, vertical: bool) -> Obj { @@ -396,11 +369,9 @@ impl Interp<'_> { return Ok(d.clone()); } let text = name.text().map(|t| t.to_string()).unwrap_or_default(); - // Another predefined CMap the file doesn't define, without its mappings: its codes are - // read by its name when type is set (see `text`). - let predefined = unicode_cmap(&text) - .map(|(_, vertical)| vertical) - .or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text))); + // A predefined CMap the file doesn't define, without its mappings: its codes are read by + // its name when type is set (see `text`). + let predefined = super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text)); match predefined { Some(vertical) => { let mut d = Dict::new(); diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 46fd148db9..3ff736d1ff 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -24,6 +24,8 @@ mod state; mod strips; pub(crate) use paint::collapse; +#[cfg(test)] +pub(crate) use paint::tilings_compared; pub(crate) use state::{GState, Out, Space, process}; pub(crate) use strips::merge_strips; diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index fc2490fb41..156b6e421e 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -5,6 +5,19 @@ use super::{ PsError, REGISTRATION, Rc, Rect, Res, SHADING_SAMPLES, Shared, UNKNOWN_FUNCTIONS, UNKNOWN_SHADINGS, Vec2, clamp_between, ps_err, }; +#[cfg(test)] +thread_local! { + /// How many patterns the lookups of tiling patterns on this thread compared with the pattern + /// they looked for. + static TILINGS_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`TILINGS_COMPARED`] of this thread. +#[cfg(test)] +pub(crate) fn tilings_compared() -> u64 { + TILINGS_COMPARED.get() +} + impl Interp<'_> { // ---------- colour ---------- @@ -212,7 +225,13 @@ impl Interp<'_> { let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; let hash = tiling_hash(&key, tile, under.as_ref()); - let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); + let made = self.out.tilings.get(&hash).and_then(|list| { + list.iter().find(|(p, t, u, _)| { + #[cfg(test)] + TILINGS_COMPARED.set(TILINGS_COMPARED.get().saturating_add(1)); + p.same(&key) && *t == tile && *u == under + }) + }); if let Some((.., name)) = made { return Ok(Paint::Pattern { pattern: name.clone(), xf }); } diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 58708bb97e..0ea7017c11 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -95,6 +95,12 @@ pub(crate) struct Interp<'a> { /// The predefined CMaps type was set in so far, by name, with how their strings decode /// (`None` when one can't be read; see `text`). pub predefined_cmaps: Vec<(Rc, Option>)>, + /// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in + /// a font of its own (see `text`). + pub kshow_font: Option, + /// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as + /// current (see `text`). + pub cshow: Option, pub g: GState, pub saved: Vec, pub out: Out, @@ -236,6 +242,8 @@ impl<'a> Interp<'a> { cid_subrs: vec![], cmap_font: 0, predefined_cmaps: vec![], + kshow_font: None, + cshow: None, type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c86cdb9516..8adcfccef0 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -191,6 +191,36 @@ fn left_out_of_comparisons(page: Page, mut restyled: Vec, placeholders: Ve restyled } +/// A single PDF page capped at the format's 14,400-unit limit, without user-space scaling. +/// The other dimension must still match; unrelated page-size mismatches remain errors. +fn capped_pdf_page(doc: &Document, visible: &Document, board: Rect, page: Rect, unit: f64) -> bool { + let capped = |side: f64, pdf: f64| (pdf - 14_400.0).abs() <= 0.5 && side > pdf + 0.5; + let matching = |side: f64, pdf: f64| (side - pdf).abs() <= 0.5 || capped(side, pdf); + unit == 1.0 + && doc.artboards.len() == 1 + && visible.artboards.len() == 1 + && (capped(board.width(), page.width()) || capped(board.height(), page.height())) + && matching(board.width(), page.width()) + && matching(board.height(), page.height()) +} +/// The PDF-covered area of a trusted oversized artboard; its full artboard stays in the document. +fn compared_page_rect( + doc: &Document, + visible: &Document, + board: Rect, + pdf: Rect, + trusted_page: Option, + unit: f64, + warnings: &mut Vec, +) -> Rect { + if matches!(trusted_page, Some(Page::Artboard)) && capped_pdf_page(doc, visible, board, pdf, unit) { + warnings.push("the editing data's artboard is larger than the PDF page: the full artboard comes from its editing data".into()); + Rect::new(board.x0, board.y0, board.x0 + pdf.width(), board.y0 + pdf.height()) + } else { + board + } +} + /// [`build`] from `structure`, the editing copy `data` already read. pub(super) fn build_from( structure: Structure, @@ -217,12 +247,13 @@ pub(super) fn build_from( placed_pages, envelopes_drawn, styles_read, + text_patterns, canvas_scale, xml_ids, bindings, bindings_skipped, warped_type, - warnings, + mut warnings, } = structure; let mut restyled = left_out_of_comparisons(page, restyled, placeholders); // Editing data in the units of the page's user space ([`in_units`]), or at its declared large @@ -270,6 +301,7 @@ pub(super) fn build_from( { (rect, cropped) = (art, true); } + rect = compared_page_rect(&doc, visible, rect, page_rect, trust.then_some(page), unit, &mut warnings); if !fits(&rect) { // Both sizes in points as the fields show them, and the editing data's page to the // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and @@ -374,7 +406,7 @@ pub(super) fn build_from( // The page's type that needs no text object of the layers: the layers' own legacy text, or // placed files that show it. let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; - let texts = Texts::read(data); + let texts = Texts::read(data).map(|t| t.with_patterns(text_patterns)); // Each story is read once, however many text objects name it. let mut read: std::collections::BTreeMap>> = Default::default(); let stories: Vec>> = infos diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs index 1a9b3b95be..c688ebf9ae 100644 --- a/crates/eps/src/import/predefined_cmap.rs +++ b/crates/eps/src/import/predefined_cmap.rs @@ -157,7 +157,7 @@ pub(super) enum Unicode { /// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, /// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` /// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). -fn unicode_cmap(name: &str) -> Option { +pub(super) fn unicode_cmap(name: &str) -> Option { let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; let rest = rest.strip_suffix("-HW").unwrap_or(rest); let (collection, form) = rest.rsplit_once('-')?; @@ -288,7 +288,7 @@ fn code_space(cmap: &str) -> Option { "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, _ if stem.ends_with("RKSJ") => RKSJ, "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "" | "78" | "Add" | "Ext" | "NWP" | "GBT" | "CNS2" => ROW_CELL, "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, "GBpc-EUC" | "GBTpc-EUC" => GB_PC, "GBK-EUC" | "GBKp-EUC" => GBK, @@ -325,6 +325,29 @@ pub(super) fn known(cmap: &str) -> bool { unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() } +/// The codes of string `bytes` in predefined CMap `cmap` when its mappings can't be read, each +/// standing for no character: as long as the code space range it falls in ([`code_space`]; a byte +/// that starts no code is a code of its own, as in the row-and-cell code space of `GBT-H` and +/// `CNS2-H`), or two bytes when the code space isn't listed. A 0x0D or 0x09 byte is always a code +/// of its own. +pub(super) fn unread_codes(cmap: &str, bytes: &[u8]) -> Decoded { + let space = code_space(cmap); + let mut out = Decoded::default(); + let mut at = 0; + while let Some(rest) = bytes.get(at..).filter(|r| !r.is_empty()) { + let len = match space { + // A 0x0D or 0x09 byte is a code of its own, as [`CMapCodes::each_code`] reads it. + _ if rest.first().and_then(|&b| control(b)).is_some() => 1, + Some(space) => code_len(space, rest).map_or(1, usize::from), + None => 2, + }; + let code = rest.get(..len).unwrap_or(rest); + out.push(code.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)), None); + at += code.len(); + } + out +} + /// Whether predefined CMap `cmap` is a vertical CMap (`-V`, or the row-and-cell `V`). pub(super) fn vertical(cmap: &str) -> bool { cmap == "V" || cmap.ends_with("-V") diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 6a1df810bc..4709a3c5bf 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -7,6 +7,9 @@ use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; +use crate::import::graphics::{GState, Out, tilings_compared}; +use crate::import::interp::Interp; +use crate::import::obj::Obj; use crate::import::{Dsc, import}; use crate::{EpsOptions, Level, Preview, Raster}; @@ -369,12 +372,19 @@ fn dash_patterns_hold_at_most_65535_entries() { /// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and /// each stroke count the entries they read or copy against the budgets. Under a pattern of -/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of -/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath` -/// run out of a budget. +/// 65,535 entries, the states that `gsave`, `grestore`, `gstate`, `currentgstate` and `setgstate` +/// save, make or set hold that one pattern, 4,000,000 passes of `gsave grestore` and 2,000,000 +/// passes of `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and +/// `strokepath` run out of a budget. #[test] fn work_on_the_longest_dash_pattern_ends_soon() { let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash"; + let shared = run_looking(&format!("{dash} gsave gsave grestore gstate gstate currentgstate dup setgstate"), |it| { + let Some(saved) = it.saved.last().map(|g| g.dash.clone()) else { return false }; + let made: Vec<_> = it.stack.iter().filter_map(|o| if let Obj::GState(g) = o { Some(g.dash.clone()) } else { None }).collect(); + saved.len() == 65_535 && made.len() == 2 && made.iter().chain([&it.g.dash]).all(|m| std::rc::Rc::ptr_eq(m, &saved)) + }); + assert!(shared); for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] { let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill")); assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings); @@ -530,13 +540,15 @@ fn non_finite_arithmetic_is_undefinedresult() { assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings); } -/// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a -/// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds. -/// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made -/// again from its prototype is the same pattern, and so is an uncolored pattern set again in a -/// color it was set in before. +/// Tiling patterns are found again through a hash of what tells them apart. A program that sets +/// 200,000 different patterns and also sets 200,001 spot inks reads them all, and its 200,000 +/// pattern lookups compare at most 200,000 earlier patterns in all; going through every pattern +/// made so far would compare about 2 × 10^10. The test does not count or time the spot ink +/// lookups. Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A +/// pattern made again from its prototype is the same pattern, and so is an uncolored pattern set +/// again in a color it was set in before. #[test] -fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { +fn many_tiling_patterns_are_found_again_through_a_hash() { let r = read( "/pp { pop 0 0 1 1 rectfill } def \ /a << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> def \ @@ -560,7 +572,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { assert!(names.iter().all(|n| d.swatch(n).is_some_and(|s| matches!(s.paint, Paint::Pattern { .. })))); // 100,000 patterns whose cells differ, an uncolored pattern in 100,000 tints of an ink that is // white at every tint (only the tint tells those patterns apart), and 200,000 more inks. - let r = read_soon( + let (r, compared) = read_counting_tilings( "0 1 99999 { /i exch def << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep i 2 add /YStep 1 \ /PaintProc { pop 0 0 1 1 rectfill } >> matrix makepattern setpattern } for \ [/Separation (Spot) /DeviceGray { pop 1 }] setcolorspace \ @@ -568,6 +580,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { matrix makepattern def 0 1 99999 { 100000 div p setpattern } for \ 0 1 199999 { /i exch def [/Separation i 8 string cvs /DeviceGray {}] setcolorspace } for 0 0 1 1 rectfill", ); + assert!(compared <= 200_000, "{compared} patterns compared"); let d = &r.document; assert_eq!(d.patterns.len(), 200_000); assert!(d.patterns.iter().enumerate().all(|(i, p)| p.name == format!("Pattern {}", i + 1))); @@ -1112,19 +1125,51 @@ fn hostile_executable_files_and_type_loops_are_refused() { blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); } -/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes -/// more than 20 s. +/// How long [`read_soon`] and [`read_counting_tilings`] wait, in seconds: a guard against a read +/// that never ends. The budgets, limits and counts each test names decide what it checks. +const NEVER_ENDS: u64 = 300; + +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading has not +/// finished after [`NEVER_ENDS`] seconds. fn read_soon(body: &str) -> crate::Imported { - read_within(body, 20) + read_within(body, NEVER_ENDS) } /// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes /// more than `secs` seconds. fn read_within(body: &str, secs: u64) -> crate::Imported { + read_counting(body, secs).0 +} + +/// [`read_soon`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting_tilings(body: &str) -> (crate::Imported, u64) { + read_counting(body, NEVER_ENDS) +} + +/// [`read_within`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting(body: &str, secs: u64) -> (crate::Imported, u64) { let (tx, rx) = std::sync::mpsc::channel(); let ps = eps(body); - std::thread::spawn(move || tx.send(import(&ps)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() + std::thread::spawn(move || tx.send((import(&ps), tilings_compared())).ok()); + let (read, compared) = rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")); + (read.unwrap(), compared) +} + +/// Runs the program of `body` in an interpreter of its own on the 100 × 100 page of [`eps`]'s +/// bounding box, and returns what `look` finds in the interpreter when the program has ended. It +/// does not read the file's comments or make a document. The test fails when the program stops on +/// an error. +fn run_looking(body: &str, look: fn(&Interp) -> T) -> T { + let file = eps(body); + let ps = super::program(&file).unwrap(); + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + let page = vectorcraft_geom::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!(it.run().is_ok(), "running {body} failed"); + let found = look(&it); + it.release(); + found } /// Reads `body` after a square: the square stays, and the program runs out of the operation diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 9e452c4bf4..8350cd514b 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -426,8 +426,6 @@ fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { ("83pv-RKSJ-H", "93fa967b41", "日本A"), ("90ms-RKSJ-H", "93fa", "日"), ("EUC-H", "c6fc", "日"), - // JIS row and cell: 日 is 0x467C. - ("H", "467c", "日"), ("GBK-EUC-H", "d6d0", "中"), ("B5pc-H", "a4a4", "中"), ("ETen-B5-H", "a4a4", "中"), @@ -448,6 +446,9 @@ fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { } let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto show"); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "中A"); + // JIS row and cell: 日 is 0x467C. + let r = super::tests::read("/X /H [/Ryumin-Light] composefont 20 scalefont setfont 10 20 moveto <467c> show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "日"); // The point moves past the type, along the line. check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and"); } @@ -478,8 +479,42 @@ fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { measured(&format!( "{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt" )); - // `xshow` moves 30 a code. + // `awidthshow` adds 5 to each code and 7 more to the space. + measured(&format!( + "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 5 0 <93fa20967b> awidthshow currentpoint pop sub -22 sub abs 0.01 lt" + )); + // `xshow` moves 30 a code, `yshow` 10 up a code, `xyshow` both. measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); + measured(&format!("{set} 10 20 moveto <93fa967b> [10 10] yshow currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); + measured(&format!("{set} 10 20 moveto <93fa967b> [30 10 30 10] xyshow currentpoint 40 sub abs 0.01 lt exch 70 sub abs 0.01 lt and")); + // `kshow` runs its procedure between each two codes: it moves the point 100 more here. The + // current font is restored when the procedure completes (PLRM 3rd ed., `kshow`), so a font it + // sets doesn't stay current, here or for a font found by name. + measured(&format!( + "{set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop 100 0 rmoveto}} <93fa967b> kshow currentpoint \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub -100 sub abs 0.01 lt and" + )); + measured(&format!( + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq" + )); + measured( + "/Helvetica findfont 20 scalefont setfont 10 20 moveto {pop pop /Courier findfont 10 scalefont setfont} (AB) kshow currentfont /FontName get /Helvetica eq", + ); + // Each procedure sees the font the file set as current, also after an earlier one set another. + measured(&format!("{set} 10 20 moveto {{pop pop currentfont /FontName get /seen exch def}} <93fa967b> kshow seen /Ryumin-Light-90ms-RKSJ-H eq")); + measured(&format!( + "{set} 10 20 moveto /n 0 def {{pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont}} {{currentfont /FontName get /seen exch def}} ifelse}} \ + <93fa967b41> kshow seen /Ryumin-Light-90ms-RKSJ-H eq" + )); + // A `kshow` the procedure runs in a font it set sees that font. + measured(&format!( + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont {{pop pop currentfont /FontName get /seen exch def}} (AB) kshow}} \ + <93fa967b> kshow seen /Helvetica eq" + )); + // The procedure gets the two codes' values (left on the stack here). + measured(&format!( + "{set} 10 20 moveto [ {{}} <93fa967b41> kshow ] dup length 4 eq exch aload pop 65 eq exch 16#967b eq and exch 16#967b eq and exch 16#93fa eq and and" + )); // Down the column: `ashow`'s 5 down each code. let set = "/Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; measured(&format!( @@ -530,10 +565,15 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { let r = super::tests::read(&format!("{set} <00660069> {show}")); let t = texts(&r); assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["fi"], "{show}"); - t[0].runs.iter().map(|r| r.style.features.clone()).collect::>() + // Spacing that differs from the advances splits the type into runs of the same features. + let mut features = t[0].runs.iter().map(|r| r.style.features.clone()).collect::>(); + features.dedup(); + features }; let plain = features("show"); - assert_eq!(plain, features("0 0 3 -1 roll ashow")); + for show in ["0 0 3 -1 roll ashow", "0 0 32 4 -1 roll widthshow", "0 0 32 0 0 6 -1 roll awidthshow", "[25 25] xshow", "{pop pop} exch kshow"] { + assert_eq!(plain, features(show), "{show}"); + } assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}"); check(&format!( "gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \ @@ -541,17 +581,106 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { )); } -/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of -/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't -/// available is left out with a warning, and a long string in a CID-keyed font over a predefined -/// CMap shown in a loop runs out of the budget. +/// `charpath`, `cshow` and `glyphshow` in a CID-keyed font over a predefined CMap, its CIDFont only +/// named: `charpath` adds the outlines of the characters the codes stand for and moves the point +/// as `show` does; `cshow`'s procedure gets each code's last byte with its width and runs with the +/// CIDFont current, a show in it setting the code's character; `glyphshow` sets the named +/// character itself. +#[test] +fn named_cid_type_in_charpath_cshow_and_glyphshow() { + let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; + let r = super::tests::read(&format!("{set} 10 20 moveto <4142> true charpath fill")); + assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); + assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{:?}", all(&r.document)); + check(&format!( + "gsave nulldevice {set} 10 20 moveto <93fa41> show currentpoint newpath 10 20 moveto <93fa41> false charpath currentpoint grestore \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); + // Down a column a code that stands for no character (0xFD) moves the outlines one em, as it + // moves the type `show` sets. + let top = |s: &str| format!("newpath 10 100 moveto <{s}> false charpath pathbbox 4 1 roll pop pop pop"); + check(&format!( + "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont {} {} grestore sub -20 sub abs 0.5 lt", + top("fd41"), + top("41") + )); + // The procedure leaves each code's last byte (PLRM 3rd ed., `cshow`): 日 (0x93FA), then A. + check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#fa eq exch 1 get 65 eq and and")); + check(&format!("{set} 0 {{pop exch pop add}} <93fa967b41> cshow <93fa967b41> stringwidth pop sub abs 0.01 lt")); + // While the procedure runs, the current font is the CIDFont and `rootfont` gives the composite + // font (PLRM 3rd ed., `cshow` and `rootfont`); the current font is restored after each run, + // also when the procedure sets another. + check(&format!( + "{set} {{pop pop pop currentfont dup /FontType get /t exch def /CIDFontName get /seen exch def}} <93fa> cshow \ + seen /Ryumin-Light eq t 9 eq and" + )); + check(&format!("{set} {{pop pop pop rootfont /FontName get /seen exch def}} <93fa> cshow seen /Ryumin-Light-90ms-RKSJ-H eq")); + check(&format!( + "{set} /n 0 def {{pop pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont rootfont /FontName get /r exch def}} \ + {{currentfont /CIDFontName get /seen exch def}} ifelse}} <93fa41> cshow seen /Ryumin-Light eq r /Helvetica eq and \ + currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq and" + )); + // A show in the CIDFont shows the character of the code the procedure was given, the string + // holding its last byte, and moves the point as far as `show` does. Any other string, an + // empty one included, is a `rangecheck` error. + for s in ["()", "(AB)", "<41>"] { + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop {s} show}} <93fa> cshow}} stopped grestore")); + } + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop show}} <93fa> cshow}} stopped not grestore")); + let r = super::tests::read(&format!("{set} 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "日本"); + check(&format!( + "gsave nulldevice {set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow \ + currentpoint grestore 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); + let r = super::tests::read(&format!("{set} 10 20 moveto /uni00E9 glyphshow")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["é"]); +} + +/// A font name ending in the row-and-cell CMap `H` or `V` stays a font of the whole name, its +/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines (or +/// an installed face that covers Japanese). Type in a predefined CMap whose mappings aren't +/// available is left out with a warning, each code moving the point one em, and a long string in a +/// CID-keyed font over a predefined CMap shown in a loop runs out of the budget. #[test] fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { - let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show"); - assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["AB"]); + for (font, family, style) in [("Futura-H", "Futura", "H"), ("Foo-Bold-H", "Foo", "Bold-H"), ("Foo-Condensed-V", "Foo", "Condensed-V")] { + let r = super::tests::read(&format!("/{font} findfont 20 scalefont setfont 10 20 moveto (AB) show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["AB"], "{font}"); + let s = &t[0].runs[0].style; + assert_eq!((s.font_family.as_str(), s.font_style.as_str()), (family, style), "{font}"); + assert!(!t[0].vertical, "{font}"); + } + // The file defines the CIDFont `Ryumin-Light`, as a resource or as a font: `Ryumin-Light-H` is + // it over the CMap `H`. + // A CIDFont name without a hyphen too, when the file defines it. + let cid_font = |name: &str| format!("/{name} << /CIDFontName /{name} /CIDFontType 0 /FontType 9 /FontMatrix [0.001 0 0 0.001 0 0] >>"); + let defines = [ + format!("{} /CIDFont defineresource pop /Ryumin-Light-H", cid_font("Ryumin-Light")), + format!("{} definefont pop /Ryumin-Light-H", cid_font("Ryumin-Light")), + format!("{} /CIDFont defineresource pop /Foo-H", cid_font("Foo")), + ]; + for define in defines { + let r = super::tests::read(&format!("{define} findfont 20 scalefont setfont 10 20 moveto <467c> show")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["日"], "{define}: {:?}", r.warnings); + } let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); + // Two two-byte codes: two ems along the line, or down the column (a square drawn when the + // point moved that far). + for cond in [ + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21212121> stringwidth abs 0.01 lt exch 40 sub abs 0.01 lt and", + "/Ryumin-Light-GBT-V findfont 20 scalefont setfont 10 100 moveto <21212121> show currentpoint 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and", + // A tab is a code of its own: three codes. + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + // A byte that starts no row-and-cell code is a code of its own, also before a tab: three. + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + ] { + let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if")); + assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}"); + } super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop"); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 7353c5c190..687834c97b 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3366,6 +3366,71 @@ fn stroked_type_keeps_its_cap_join_and_miter_limit() { ); } +#[test] +fn type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names() { + // GUARDS: type filled or stroked with a pattern swatch came in black: the text document keeps + // black as the paint's colour, and the pattern is in the art style that key 5 of the paint + // names, a style of one paint (`/SimpleStyle`) whose matrix is in art space. + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::Point; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let simple = |name: &str, paint: &str| { + format!( + "/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n /Def ;\n" + ) + }; + let pattern = |m: &str| format!("(Dots) 0 0 1 1 0 0 0 0 0 [{m}] p"); + let styles = [ + simple("Anon 1", &pattern("1 0 0 1 0 0")), + simple("Anon 2", &pattern("2 0 0 2 10 20")), + // Styles that give no pattern: one the file doesn't define, a matrix that doesn't place, no + // name, a colour after the pattern, a colour, a gradient. + simple("Anon 3", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 4", &pattern("0 0 0 0 0 0")), + simple("Anon 5", "0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 6", &format!("{}\n0 0 0 1 k", pattern("1 0 0 1 0 0"))), + simple("Anon 7", "0 0 0 1 k"), + simple("Anon 8", "0 Bb\n2 (Rainbow) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"), + ] + .concat(); + let styles = format!("%AI9_BeginArtStyles\n{styles}%AI9_EndArtStyles\n"); + let doc = text_document(&[("abcdefghijklmnopq\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let one = "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 18 >>"; + assert!(doc.contains(one)); + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> /5 ({style}) >>"); + let run = |own: &str| format!("<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own} >> >> >> /1 2 >>"); + let mut runs = vec![run(&format!("/53 {}", black("Anon 1"))), run(&format!("/53 {} /57 true /54 {} /63 2.0", black("Anon 7"), black("Anon 2")))]; + runs.extend((3..=8).map(|n| run(&format!("/53 {}", black(&format!("Anon {n}")))))); + // A style the file doesn't have, over "q" and the story's end. + runs.push(run(&format!("/53 {}", black("Anon 9")))); + let doc = doc.replacen(one, &runs.join(" "), 1); + let art = native_with_text(&format!("{dots}{styles}{}{}", layer("Back", true, &square(10, 10)), layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + // Each character's fill and stroke, a pattern as where its tile's top left corner is. + let paint = |p: &Paint| match p { + Paint::Pattern { pattern, xf } => { + let at = t.xf * *xf * Point::ZERO; + format!("{pattern} at {:.3} {:.3}", at.x, at.y) + } + Paint::Solid { color, .. } if *color == Color::gray(1.0) => "black".into(), + Paint::None => "none".into(), + other => format!("{other:?}"), + }; + let chars: Vec<(char, String, String)> = + t.runs.iter().flat_map(|r| r.text.chars().map(|c| (c, paint(&r.style.fill), paint(&r.style.stroke))).collect::>()).collect(); + let want = |c: char, fill: &str, stroke: &str| (c, fill.to_string(), stroke.to_string()); + // The tile's top left (0, 4) in art space, on a page 100 high: (0, 96). Scaled twice and moved + // by (10, 20): (10, 28) in art space, (10, 72) in the document. + let mut expected = vec![want('a', "Dots at 0.000 96.000", "none"), want('b', "Dots at 0.000 96.000", "none")]; + expected.extend(['c', 'd'].map(|c| want(c, "black", "Dots at 10.000 72.000"))); + expected.extend("efghijklmnopq".chars().map(|c| want(c, "black", "none"))); + assert_eq!(chars, expected); + assert_eq!(r.document.pattern("Dots").map(|p| p.tile.height()), Some(4.0)); +} + #[test] fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { @@ -6940,3 +7005,21 @@ fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); } + +/// #6865: explicitly trusting editing data keeps an artboard larger than its PDF page. +#[test] +fn trusted_editing_data_keeps_an_artboard_taller_than_its_pdf_page() { + let art = native(&layer("Oversized", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 12992.726 14637.147"); + let (d, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, true, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Oversized"], "{w:?}"); + assert!((d.artboards[0].rect.height() - 14637.147).abs() < 0.001); + assert_eq!(kinds(&d.layers[0]), ["path"]); + assert!(w.iter().any(|w| w.contains("artboard is larger than the PDF page")), "{w:?}"); + let child = &d.layers[0].children().unwrap()[0]; + assert!(child.visual_bounds().unwrap().y0 > 14400.0, "art beyond the PDF page is kept"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, false, 1.0); + assert!(!read, "default import still rejects the size mismatch: {w:?}"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12000.0, 14400.0), vec![], false, true, 1.0); + assert!(!read, "the uncapped dimension must still match: {w:?}"); +} diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index ee9b0fc759..989450f440 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -171,8 +171,8 @@ impl Interp<'_> { ResourceForAll => self.resource_for_all()?, Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => { let (s, spacing) = self.show_operands(op)?; - if let Some((codes, font, vertical)) = self.named_cid_type(&s)? { - self.show_named_cid(&codes, &font, vertical, &spacing)?; + if let Some(named) = self.named_cid_type(&s)? { + self.show_named_cid(&named, &spacing, Draw::Fill)?; } else if let Some(glyphs) = self.composite_glyphs(&s)? { self.show_composite(&glyphs, &spacing)?; } else { @@ -186,6 +186,16 @@ impl Interp<'_> { // The procedure shows or places each character: it gets its code and width. let s = self.pop_str()?.to_vec(); let proc = self.pop_proc()?; + // In a CIDFont only named, a code may be several bytes: the procedure runs once for + // each code. + if let Some(named) = self.named_cid_type(&s)? { + let root = self.g.font.clone(); + let before = self.cshow.take(); + let r = self.named_cshow(named, &proc, root.clone()); + self.cshow = before; + self.g.font = root; + return r; + } for b in s { let v = self.width_of(&[b])?; self.push(Obj::Int(i64::from(b)))?; @@ -208,7 +218,14 @@ impl Interp<'_> { } None => { let ch = glyph_char(&name); - self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?; + // In a CIDFont only named, the character is set as it is, not read through + // the CMap. + if let Some(mut named) = self.named_cid_font()? { + named.codes.push(0, Some(ch).filter(|c| !c.is_control())); + self.show_named_cid(&named, &Spacing::Plain, Draw::Fill)?; + } else { + self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?; + } } } } @@ -228,7 +245,12 @@ impl Interp<'_> { SetCharWidth => self.glyph_width = Some(self.nums::<2>()?), SetCacheDevice2 => self.glyph_width = Some(self.nums::<10>()?[..2].try_into().unwrap_or_default()), RootFont => { - let font = match self.g.font.clone() { + // Inside a `cshow` procedure the composite font, unless the procedure set a font. + let root = match (&self.cshow, &self.g.font) { + (Some(c), Some(f)) if f.same(&c.selected) => Some(c.root.clone()), + _ => None, + }; + let font = match root.or_else(|| self.g.font.clone()) { Some(f) => f, None => self.find_font(&Obj::name(DEFAULT_FONT))?, }; @@ -283,11 +305,8 @@ impl Interp<'_> { /// How far `s` moves the current point (user space). fn width_of(&mut self, s: &[u8]) -> Res { - if let Some((codes, font, vertical)) = self.named_cid_type(s)? { - let saved = self.g.font.replace(font); - let r = self.named_cid_width(&codes, vertical); - self.g.font = saved; - return r; + if let Some(named) = self.named_cid_type(s)? { + return self.named_cid_width(&named, &named.codes); } if let Some(glyphs) = self.composite_glyphs(s)? { let mut w = Vec2::ZERO; @@ -399,25 +418,23 @@ impl Interp<'_> { /// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font, /// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap /// resources, ); else a font of that name. - /// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the - /// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so - /// `Futura-H` stays a font of that name. + /// A name that ends in a predefined CMap other than a Unicode CMap and that an installed face + /// has is that face's ([`Self::predefined_split`]). fn find_font(&mut self, k: &Obj) -> Res { let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?; if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) { return Ok(d.clone()); } let text = k.text().map(|t| t.to_string()).unwrap_or_default(); - let split = text + let unicode = text .match_indices('-') .map(|(i, _)| i) - .find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) - .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) - .or_else(|| { - let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?; - let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some(); - (!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap)) - }); + .find(|&i| i > 0 && super::predefined_cmap::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) + .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))); + let split = match unicode { + Some(split) => Some(split), + None => self.predefined_split(&text), + }; if let Some((cid_font, cmap)) = split { let cmap = self.find_cmap(&Obj::name(cmap))?; let dep = self.find_cid_font(&Obj::name(cid_font))?; @@ -434,6 +451,32 @@ impl Interp<'_> { Ok(font) } + /// Font name `name` as a CIDFont's name and the name of a predefined CMap other than a Unicode + /// CMap ([`super::predefined_cmap::composite_font`]), unless an installed face has the whole + /// name. A name that ends in the row-and-cell CMap `H` or `V` splits only when the part before + /// it names a CIDFont the file defines, or has a hyphen and names an installed face that covers + /// Japanese; otherwise it stays a font of the whole name (`Futura-H`, `Foo-Bold-H`), its + /// strings read byte by byte. + fn predefined_split<'a>(&mut self, name: &'a str) -> Option<(&'a str, &'a str)> { + let (cid_font, cmap) = super::predefined_cmap::composite_font(name)?; + let db = vectorcraft_text::FontDb::global(); + if db.find_postscript(name).is_some() { + return None; + } + if matches!(cmap, "H" | "V") { + let key = Key::name(cid_font); + // As a CIDFont resource, or as a font (`definefont`), as `composefont` finds it. + let cid_font_in = |d: &DictRef| matches!(d.borrow().get(&key), Some(Obj::Dict(f)) if f.borrow().contains_key(&Key::name("CIDFontType"))); + let defined = self.instances(&Obj::name("CIDFont")).is_ok_and(|d| d.borrow().contains_key(&key)) || cid_font_in(&self.fonts); + // 日 is in the row-and-cell CMaps' character set (JIS X 0208). + let installed = || db.find_postscript(cid_font).is_some_and(|face| face.covers('日')); + if !(defined || (cid_font.contains('-') && installed())) { + return None; + } + } + Some((cid_font, cmap)) + } + /// The current font's name and its matrix for a font of one unit (user space). fn font(&mut self) -> Res<(String, Affine)> { let font = match self.g.font.clone() { @@ -575,15 +618,25 @@ impl Interp<'_> { codes.push(u32::from(code), c.filter(|c| !c.is_control())); } let widths: Option> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect()); - self.show_codes(&codes, widths.as_deref(), spacing, false, s) + self.show_codes(&codes, widths.as_deref(), spacing, false, s, Draw::Fill) } /// The codes `codes` (each standing for some of its characters, or none) shown in the current /// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths` /// wide when given (in glyph space, one unit an em), else as wide as its characters' advances; - /// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the - /// string `kshow` gives its procedure the codes of. - fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res { + /// as vertical type when `vertical`, the codes moving the point down the column. `kern` holds + /// the codes `kshow` gives its procedure, one for each code. With `draw` [`Draw::Path`] the + /// type's glyph outlines are added to the current path, placed as the type would be + /// (`charpath`). + fn show_codes>( + &mut self, + codes: &Decoded, + widths: Option<&[f64]>, + spacing: &Spacing, + vertical: bool, + kern: &[C], + draw: Draw, + ) -> Res { let at = self.user_point()?; let (_, f1) = self.font()?; let text = &codes.text; @@ -627,8 +680,9 @@ impl Interp<'_> { return ps_err("undefinedresult", "a show"); } let across = moves.iter().any(|m| m.2.abs() > size * 1e-3); - // Nothing shows on the null device (`nulldevice`, a Type 3 glyph only measured). - let painted = !self.g.paint.is_none() && !self.g.null; + // Nothing shows on the null device (`nulldevice`, a Type 3 glyph only measured); + // `charpath` adds the outlines there too. + let painted = draw == Draw::Path || (!self.g.paint.is_none() && !self.g.null); if across { // Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it. let mut pen = at; @@ -637,8 +691,12 @@ impl Interp<'_> { let c: String = chars.by_ref().take(n).collect(); if painted && !c.trim().is_empty() { let (t, ..) = self.set_text_in(&c, pen, vertical)?; - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + if draw == Draw::Path { + self.type_path(&t)?; + } else { + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + } } pen += *d; } @@ -686,27 +744,62 @@ impl Interp<'_> { if lead != Vec2::ZERO { t.xf = Affine::translate(self.xf() * Point::new(lead.x, lead.y) - self.xf() * Point::ZERO) * t.xf; } - let clips = self.g.clips.clone(); - if self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips).is_some() - && let Some(after) = self.out.drawn.get_mut(start..) - { - after.rotate_right(1); + if draw == Draw::Path { + self.type_path(&t)?; + } else { + let clips = self.g.clips.clone(); + if self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips).is_some() + && let Some(after) = self.out.drawn.get_mut(start..) + { + after.rotate_right(1); + } } } self.g.cur = Some(self.xf() * (at + total)); Ok(()) } - /// `kshow` between character `i` of `s` and the next: its procedure run with the two codes - /// and the current point at user point `pen` (PLRM 3rd ed., `kshow`: the procedure may - /// change the current point, as kerning does) → how far it moved the point (user space). + /// The glyph outlines of point type `t` added to the current path (`charpath`). + fn type_path(&mut self, t: &TextObject) -> Res { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + for g in &layout.glyphs { + let mut o = g.outline.clone(); + o.apply_affine(t.xf); + self.extend_path(o)?; + } + Ok(()) + } + + /// Glyph outline `o` added to the current path, unless the path would pass its most points. + fn extend_path(&mut self, o: vectorcraft_geom::BezPath) -> Res { + if self.g.path.elements().len().saturating_add(o.elements().len()) > super::graphics::MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + self.g.path.extend(o); + Ok(()) + } + + /// `kshow` between character `i` of `s` (its codes) and the next: its procedure run with the + /// two codes and the current point at user point `pen` (PLRM 3rd ed., `kshow`: the procedure + /// may change the current point, as kerning does) → how far it moved the point (user space). /// Nothing for other show operators or after the last character. - fn kern(&mut self, spacing: &Spacing, s: &[u8], i: usize, pen: Point) -> Res { + fn kern>(&mut self, spacing: &Spacing, s: &[C], i: usize, pen: Point) -> Res { let (Spacing::Kern(p), Some(&code), Some(&next)) = (spacing, s.get(i), s.get(i + 1)) else { return Ok(Vec2::ZERO) }; self.g.cur = Some(self.xf() * pen); - self.push(Obj::Int(i64::from(code)))?; - self.push(Obj::Int(i64::from(next)))?; - self.call(p.clone())?; + self.push(Obj::Int(code.into()))?; + self.push(Obj::Int(next.into()))?; + // The procedure sees the font the file set, not the one type in a CIDFont only named is + // set in, and the current font is restored when it completes (PLRM 3rd ed., `kshow`). + // A `kshow` the procedure runs sees the font the procedure set. + let outer = self.kshow_font.take(); + let inner = match outer.clone() { + Some(outer) => self.g.font.replace(outer), + None => self.g.font.clone(), + }; + let r = self.call(p.clone()); + self.kshow_font = outer; + self.g.font = inner; + r?; Ok(self.user_point()? - pen) } @@ -757,15 +850,14 @@ impl Interp<'_> { if paint == Draw::Fill { return self.fill_path(outline, vectorcraft_geom::FillRule::NonZero); } - if self.g.path.elements().len().saturating_add(outline.elements().len()) > super::graphics::MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - self.g.path.extend(outline); - Ok(()) + self.extend_path(outline) } /// `charpath`: the glyph outlines of `s` added to the path. fn char_path(&mut self, s: &[u8]) -> Res { + if let Some(named) = self.named_cid_type(s)? { + return self.show_named_cid(&named, &Spacing::Plain, Draw::Path); + } if let Some(glyphs) = self.composite_glyphs(s)? { for g in &glyphs { let at = self.user_point()?; @@ -791,10 +883,9 @@ impl Interp<'_> { let at = self.user_point()?; let (t, layout, adv) = self.set_type(s, at)?; for g in &layout.glyphs { - self.grow()?; let mut o = g.outline.clone(); o.apply_affine(t.xf); - self.g.path.extend(o); + self.extend_path(o)?; } self.g.cur = Some(self.xf() * (at + adv)); Ok(()) @@ -855,6 +946,25 @@ struct Mapped { vertical: bool, } +/// Type in a CIDFont only named, over a predefined CMap ([`Interp::named_cid_type`]): its codes +/// with their characters, the font it is set in, whether it runs down a column, and whether each +/// code moves the point one em (down a column, or in a CMap VectorCraft can't read). +struct NamedCid { + codes: Decoded, + font: DictRef, + vertical: bool, + ems: bool, +} + +/// A `cshow` procedure running over type in a CIDFont only named ([`Interp::named_cshow`]): the +/// CIDFont it sees as current, the composite font `rootfont` gives while that CIDFont is current, +/// and the type of the code it was given. +pub(super) struct CShowFont { + selected: DictRef, + root: DictRef, + code: NamedCid, +} + /// What drawing a glyph does with it. #[derive(Clone, Copy, PartialEq)] enum Draw { @@ -986,9 +1096,19 @@ impl Interp<'_> { /// of that name and its character collection's CID → Unicode CMap (a code it doesn't map /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't - /// read is left out, with a warning. - fn named_cid_type(&mut self, s: &[u8]) -> Res> { - let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; + /// read is left out, with a warning, each of its codes moving the point one em. + /// Inside a `cshow` procedure over such type, with the CIDFont current, the string must be the + /// one byte the procedure was given, else a `rangecheck` error, and it stands for the code + /// the procedure was given (PLRM 3rd ed., `cshow`: the glyph of the selected CID). + fn named_cid_type(&mut self, s: &[u8]) -> Res> { + if let Some(named) = self.cshow_code(true) { + let given = named.codes.codes.first().map(|&(code, _)| code & 0xff); + if s.len() != 1 || given != s.first().map(|&b| u32::from(b)) { + return ps_err("rangecheck", "a string shown in cshow"); + } + return Ok(Some(named)); + } + let Some(cmap) = self.named_cid_parts().map(|(cmap, ..)| cmap) else { return Ok(None) }; // Each code is looked up in the CMap's mappings. self.spend(s.len() as u64)?; // The decoded codes and characters are held while the string is shown: checked against @@ -996,25 +1116,53 @@ impl Interp<'_> { let held = s.len().saturating_mul(DECODED_BYTES); self.alloc(held)?; self.unalloc(held); - let decoded = match self.predefined_codes(&cmap) { - Some(codes) => codes.codes(s), + let (decoded, read) = match self.predefined_codes(&cmap) { + Some(codes) => (codes.codes(s), true), None => { self.out.warn(UNREAD_CMAP); - Decoded::default() + (super::predefined_cmap::unread_codes(&cmap, s), false) } }; - let mut codes = Decoded::default(); + let Some(mut named) = self.named_cid_font()? else { return Ok(None) }; let mut chars = decoded.text.chars(); for &(code, n) in &decoded.codes { - codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); + named.codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); } + named.ems |= !read; + Ok(Some(named)) + } + + /// The font [`Self::named_cid_type`] sets type in, with no codes yet, when the current font is + /// a CID-keyed font over a predefined CMap the file doesn't define. + fn named_cid_font(&mut self) -> Res> { + if let Some(named) = self.cshow_code(false) { + return Ok(Some(named)); + } + let Some((_, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. let entries = font.borrow().len(); self.spend(entries as u64)?; let mut named = font.borrow().clone(); named.insert(Key::name("FontName"), name); - Ok(Some((codes, DictRef::new(named), vertical))) + Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical })) + } + + /// The type of the code a running `cshow` procedure was given ([`Self::named_cshow`]), with + /// that code when `code` and with none otherwise, while the CIDFont it selected is current. + fn cshow_code(&self, code: bool) -> Option { + let c = self.cshow.as_ref()?; + if !self.g.font.as_ref().is_some_and(|f| f.same(&c.selected)) { + return None; + } + let mut codes = Decoded::default(); + if code { + let mut chars = c.code.codes.text.chars(); + for &(value, n) in &c.code.codes.codes { + codes.push(value, chars.by_ref().take(n)); + } + } + Some(NamedCid { codes, font: c.code.font.clone(), vertical: c.code.vertical, ems: c.code.ems }) } /// The CMap's name, the composite font, the CIDFont's name and the writing mode of @@ -1032,7 +1180,7 @@ impl Interp<'_> { return None; } let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string(); - if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) { + if !super::predefined_cmap::known(&cmap_name) { return None; } let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num); @@ -1047,7 +1195,7 @@ impl Interp<'_> { /// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a /// file); `None` when VectorCraft can't read it. fn predefined_codes(&mut self, cmap: &str) -> Option> { - if let Some((form, _)) = super::cid::unicode_cmap(cmap) { + if let Some(form) = super::predefined_cmap::unicode_cmap(cmap) { return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form))); } if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) { @@ -1060,32 +1208,81 @@ impl Interp<'_> { codes } - /// The codes `codes` (with their characters) set as point type at the current point in font - /// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves - /// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and - /// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed., - /// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's - /// codes aren't single bytes). Down a column each code moves the point one em, the default - /// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, - /// §5.6.3, `DW2`). - fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res { - let widths = vertical.then(|| vec![1.0; codes.codes.len()]); - let saved = self.g.font.replace(font.clone()); - let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]); + /// Type `named` (see [`Self::named_cid_type`]) set as point type at the current point in its + /// font, running down a column when it is vertical, which moves past it, spaced as `spacing` + /// says, as [`Self::show_codes`] shows codes. `widthshow` and `awidthshow` match a code by its + /// value (font number 0 times 256 plus the code: PLRM 3rd ed., `widthshow` in chapter 8), and + /// `kshow`'s procedure gets the values of the two codes it runs between. Down a column, and in + /// a CMap VectorCraft can't read, each code moves the point one em, the default displacement + /// of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, §5.6.3, `DW` and + /// `DW2`). With `draw` [`Draw::Path`] the outlines go on the current path (`charpath`). + fn show_named_cid(&mut self, named: &NamedCid, spacing: &Spacing, draw: Draw) -> Res { + let widths = named.ems.then(|| vec![1.0; named.codes.codes.len()]); + let kern: Vec = match spacing { + Spacing::Kern(_) => named.codes.codes.iter().map(|&(code, _)| code).collect(), + _ => Vec::new(), + }; + let saved = self.g.font.replace(named.font.clone()); + let before = std::mem::replace(&mut self.kshow_font, saved.clone()); + let r = self.show_codes(&named.codes, widths.as_deref(), spacing, named.vertical, &kern, draw); + self.kshow_font = before; self.g.font = saved; r } - /// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`] - /// shows them with no extra spacing. - fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res { + /// `cshow`'s procedure `proc` run for each code of type `named` in composite font `root`, with + /// the code's last byte and its width (PLRM 3rd ed., `cshow`). While it runs the CIDFont is the + /// current font and `rootfont` gives `root`; a show operator in the CIDFont shows the code's + /// character, whatever its string (see [`Self::named_cid_type`]). The current font is restored + /// when the procedure completes. + fn named_cshow(&mut self, named: NamedCid, proc: &Obj, root: Option) -> Res { + let Some(composite) = root.clone() else { return Ok(()) }; + let dep = named.font.borrow().get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)); + let selected = match dep { + Some(Obj::Dict(d)) => d, + _ => named.font.clone(), + }; + let mut chars = named.codes.text.chars(); + for &(code, n) in &named.codes.codes { + let mut one = NamedCid { codes: Decoded::default(), font: named.font.clone(), vertical: named.vertical, ems: named.ems }; + one.codes.push(code, chars.by_ref().take(n)); + let v = self.named_cid_width(&one, &one.codes)?; + self.push(Obj::Int(i64::from(code & 0xff)))?; + self.push_num(v.x)?; + self.push_num(v.y)?; + self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: one }); + self.g.font = Some(selected.clone()); + let go = self.body(proc); + self.g.font = root.clone(); + if !go? { + break; + } + } + Ok(()) + } + + /// How far codes `codes` of type `named` move the current point (user space) when + /// [`Self::show_named_cid`] shows them with no extra spacing. + fn named_cid_width(&mut self, named: &NamedCid, codes: &Decoded) -> Res { + let saved = self.g.font.replace(named.font.clone()); + let r = self.named_cid_moved(codes, named.vertical, named.ems); + self.g.font = saved; + r + } + + /// [`Self::named_cid_width`] in the current font: codes `codes`, vertical when `vertical`, + /// each one em when `ems`. + fn named_cid_moved(&mut self, codes: &Decoded, vertical: bool, ems: bool) -> Res { let (_, f1) = self.font()?; - let moved = if vertical { - Point::new(0.0, -(codes.codes.len() as f64)) - } else { - let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; - let size = t.runs.first().map_or(1.0, |r| r.style.size); - Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + let n = codes.codes.len() as f64; + let moved = match (vertical, ems) { + (true, _) => Point::new(0.0, -n), + (false, true) => Point::new(n, 0.0), + (false, false) => { + let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; + let size = t.runs.first().map_or(1.0, |r| r.style.size); + Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + } }; Ok(f1 * moved - f1 * Point::ZERO) } @@ -1393,7 +1590,7 @@ const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry const DECODED_BYTES: usize = 64; /// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have. -pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out"; +pub(super) const UNREAD_CMAP: &str = "type in a CMap that couldn't be read was left out"; /// Most Type 42 fonts whose TrueType data is kept between glyphs. const MAX_TYPE42: usize = 16; diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 2a09cf003c..04c916d324 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -113,6 +113,8 @@ const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the const FREEFORM: &str = "freeform gradients are written as their average colour"; const CONIC: &str = "conic gradients are written as solid wedges clipped to the shape (legacy .ai files have no conic gradient)"; 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"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = @@ -156,6 +158,8 @@ pub enum HiddenObjects { pub struct LegacyAiOutput { pub bytes: Vec, pub warnings: Vec, + /// Notes on what the options asked for: hidden objects left out or written visible. + pub setting_notes: Vec, } /// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color @@ -267,7 +271,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result= 3 * CONIC_WEDGES, "a wedge a degree for each run"); +} + +/// A gradient repeated more times than the reader keeps stops for is written with no more stops +/// than it reads, with a warning, so it opens with every stop that was written. +#[test] +fn many_repeats_are_written_within_the_stops_the_reader_reads() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + 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))]; + // A run of 0.4 pt across a shape 160 pt wide: 400 runs. + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(20.0, 50.0), + end: vectorcraft_geom::Point::new(60.0, 50.0), + aspect: 1.0, + focal: None, + }); + 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 == SPREAD_CAPPED), "{:?}", 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(); + assert!((2..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{written} stops"); + let read = crate::import(&out.bytes).unwrap().document; + let mut stops = None; + read.walk(|n| { + if let (None, Paint::Gradient(g)) = (&stops, n.appearance.fill_paint()) { + stops = Some(g.gradient.stops.len()); + } + }); + assert_eq!(stops, Some(written), "every stop written is read"); +} + +/// 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] +fn far_runs_keep_their_places() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + 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 mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Radial, stops) }); + // Runs 0.25 pt apart, the shape 480 runs and more from the center. + let c = vectorcraft_geom::Point::new(-100.0, 50.0); + g.geom = Some(GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(0.25, 0.0), aspect: 1.0, focal: None }); + let bounds = Rect::new(20.0, 10.0, 180.0, 90.0); + let padded = g.gradient.padded_within(&g.geom.unwrap(), bounds, crate::import::MAX_GRADIENT_STOPS).unwrap(); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(bounds), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let text = String::from_utf8_lossy(&write(&doc).bytes).into_owned(); + let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap(); + let ramps: Vec = def.lines().filter_map(|l| l.strip_suffix(" %_Bs")).map(|l| l.rsplit(' ').next().unwrap().parse().unwrap()).collect(); + assert_eq!(ramps.len(), padded.gradient.stops.len()); + for (ramp, stop) in ramps.iter().zip(&padded.gradient.stops) { + assert!((ramp / 100.0 - f64::from(stop.offset)).abs() < 2e-7, "{ramp} for {}", stop.offset); + } +} + /// The format has no diamond gradient: a diamond fill, and a diamond stroke (written as its filled /// outline), are four path objects clipped to the shape, each a linear gradient over a quadrant /// of the diamond's frame, with a warning; the definition is not named after the swatch. @@ -139,6 +252,32 @@ fn diamond_gradients_are_written_as_four_linear_gradients_clipped_to_the_shape() } } +/// A diamond gradient that repeats is four linear gradients over the quadrants of its frame, each +/// with its stops repeated out to the shape's edge, with a warning. +#[test] +fn a_spread_diamond_repeats_in_each_quadrant() { + use vectorcraft_color::GradientSpread; + 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 mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(GradientKind::Diamond, stops) }); + g.geom = Some(vectorcraft_color::GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 0.7, + focal: None, + }); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::ellipse(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 == DIAMOND) && out.warnings.iter().any(|w| w == SPREAD), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + assert_eq!(text.matches(" Bg\n").count(), 4, "a gradient a quadrant"); + let defs: Vec = + text.split("%AI5_BeginGradient: ").skip(1).map(|d| d.split("%AI5_EndGradient").next().unwrap_or("").matches(" %_Bs\n").count()).collect(); + assert!(!defs.is_empty() && defs.iter().all(|&n| n >= 6), "stops in each definition: {defs:?}"); +} + /// `doc` written with options `o`. Every file the tests write is also run as a PostScript program /// ([`prints_as`]) and must draw what `doc` draws. fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput { @@ -616,6 +755,28 @@ fn a_gradient_fill_and_its_stroke_are_one_object() { assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// An RGB gradient stop's CMYK equivalent is the one of its RGB values as written (to four +/// decimals), so the reopened file writes the same stops. +#[test] +fn rgb_stops_are_written_again_unchanged() { + let mut doc = Document::new(100.0, 100.0); + let rgb = |i: u16| { + let v = |s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + Color::rgb(v(0.137_911_3), v(0.271_828_2), v(0.314_159_3)) + }; + let stops = (0..12u16).map(|i| GradientStop::new(f32::from(i) / 11.0, rgb(i))).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), paint, Paint::None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = |bytes: Vec| String::from_utf8(bytes).unwrap().lines().filter(|l| l.ends_with("%_Bs")).map(String::from).collect::>(); + let first = write(&doc).bytes; + let again = write(&crate::import(&first).unwrap().document).bytes; + let (first, again) = (stops(first), stops(again)); + assert_eq!(first.len(), 12, "{first:?}"); + assert_eq!(again, first); +} + /// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted /// to the object). fn white_to_navy(kind: GradientKind, geom: Option) -> Paint { @@ -1733,12 +1894,12 @@ fn hidden_objects_go_to_a_hidden_layer_above_their_own() { #[test] fn hidden_objects_can_be_left_out_or_shown() { let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Omit, ..LegacyAiOptions::default() }); - assert!(out.warnings.iter().any(|w| w == HIDDEN), "{:?}", out.warnings); + assert!(out.setting_notes.iter().any(|w| w == HIDDEN), "{:?}", out.setting_notes); let doc = crate::import(&out.bytes).unwrap().document; assert_eq!(doc.layers.len(), 2); assert_eq!(children(&children(&doc.layers[0])[0]).len(), 1); let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Visible, ..LegacyAiOptions::default() }); - assert!(out.warnings.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.warnings); + assert!(out.setting_notes.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.setting_notes); let doc = crate::import(&out.bytes).unwrap().document; assert_eq!(doc.layers.len(), 2); let pair = children(&children(&doc.layers[0])[0]); diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 032317ab7a..557c59ab34 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}; +use super::{CONIC, CONIC_WEDGES, DIAMOND, SPREAD, SPREAD_CAPPED}; pub(super) struct Writer<'a> { doc: &'a Document, @@ -24,6 +24,8 @@ pub(super) struct Writer<'a> { /// The name each spot color (by its swatch's name) is written with ([`Self::spot`]). spot_names: Vec<(String, String)>, pub(super) warnings: Vec, + /// Notes on what the options asked for ([`crate::LegacyAiOutput::setting_notes`]). + pub(super) setting_notes: Vec, /// The lock state the last `A` set. locked: bool, /// Symbols and live objects being written inside one another. @@ -56,6 +58,7 @@ impl<'a> Writer<'a> { spots: vec![], spot_names: vec![], warnings: vec![], + setting_notes: vec![], locked: false, nest: 0, hidden: o.hidden, @@ -68,12 +71,21 @@ impl<'a> Writer<'a> { } } + /// Note `w`, once, about something of the document that the file leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about hidden objects left out or written visible as an option asks. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn line(&mut self, s: &str) { self.body.push_str(s); self.body.push('\n'); @@ -225,7 +237,7 @@ impl<'a> Writer<'a> { if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { - self.warn(HIDDEN); + self.note_setting(HIDDEN); return; } HiddenObjects::Layer => { @@ -233,7 +245,7 @@ impl<'a> Writer<'a> { self.hidden_art.push((n.clone(), locked || n.locked)); return; } - HiddenObjects::Visible => self.warn(HIDDEN_SHOWN), + HiddenObjects::Visible => self.note_setting(HIDDEN_SHOWN), } } if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { @@ -487,7 +499,8 @@ impl<'a> Writer<'a> { self.warn(TRANSPARENCY); } let area = path.to_bezpath().bounding_box(); - let wedges = g.resolve(area).conic_wedges(area, CONIC_WEDGES, true); + let wedges = g.resolve(area).conic_wedges(area, g.gradient.conic_wedge_count(CONIC_WEDGES), true); + let spread = g.gradient.spreader(); self.line("q"); self.fill_rule(rule); let several = path.subpaths.len() > 1; @@ -501,7 +514,7 @@ impl<'a> Writer<'a> { self.line("*U"); } for (pts, t) in wedges { - let (color, _) = g.gradient.sample_keep_model(t as f32); + let (color, _) = g.gradient.sample_keep_model(spread(t) as f32); let wedge = FillLayer { paint: Paint::solid(color), ..f.clone() }; self.path_object(&PathData::single(SubPath::polyline(&pts, true)), FillRule::NonZero, Some(&wedge), None); } @@ -632,7 +645,8 @@ impl<'a> Writer<'a> { // Grey is ink coverage; the format's grey is lightness. Color::Gray { k } => Ink::Gray(unit(1.0 - k)), Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)), - Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)), + // The CMYK equivalent of the RGB values as written. + Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(&ai_ops::as_written(*color), intent).map(unit), [r, g, b].map(unit)), Color::Lab { .. } => { let rgb = color.to_rgb().map(unit); Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb) @@ -678,6 +692,22 @@ 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 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. + if let Some(padded) = g.gradient.padded_within(&g.resolve(bounds), bounds, crate::import::MAX_GRADIENT_STOPS) { + self.warn(if padded.capped { SPREAD_CAPPED } else { SPREAD }); + let geom = padded.geom; + let p = GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } + // One whose levels over `bounds` are not finite (a vector of no length): its stops + // repeated along the vector, padding past it. + if g.gradient.is_spread() { + self.warn(SPREAD_CAPPED); + let p = GradientPaint { gradient: g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS).into_owned(), swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } let name = self.define(&g.gradient, g.swatch.as_deref()); for stop in &g.gradient.stops { if stop.opacity < 1.0 { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 586967234b..f6b7fba609 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -195,6 +195,8 @@ pub struct EpsOutput { pub bytes: Vec, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: the preview or thumbnail asked for that was left out. + pub setting_notes: Vec, } /// The `%%BoundingBox` of `region` with PostScript's origin at document point `origin`: whole @@ -229,11 +231,11 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); - let mut warnings = page.warnings; + let mut setting_notes = vec![]; let thumb = thumbnail.and_then(|t| match tiff::png(t) { Ok(png) => Some(png), Err(e) => { - warnings.push(format!("the thumbnail was left out: {e}")); + setting_notes.push(format!("the thumbnail was left out: {e}")); None } }); @@ -241,7 +243,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let bytes = match (opts.preview, preview) { (Preview::None, _) => ps.into_bytes(), (_, None) => { - warnings.push("the preview was left out: nothing was rendered for it".into()); + setting_notes.push("the preview was left out: nothing was rendered for it".into()); ps.into_bytes() } (kind, Some(img)) => { @@ -249,7 +251,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb dos_eps(ps.as_bytes(), &tiff)? } }; - Ok(EpsOutput { bytes, warnings }) + Ok(EpsOutput { bytes, warnings: page.warnings, setting_notes }) } /// Comment lines that start a data section the file carries (`%VectorCraft_BeginData: `) diff --git a/crates/eps/src/print.rs b/crates/eps/src/print.rs index 99dffd8225..0ccd7a6331 100644 --- a/crates/eps/src/print.rs +++ b/crates/eps/src/print.rs @@ -181,7 +181,7 @@ pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result String { /// Append [`num`]`(v)` to `s`. pub(crate) fn push_num(s: &mut String, v: f64) { - // Beyond ~1.8e304 the scaling overflows; such values have no fractional digits to round. + push_num_places(s, v, 4); +} + +/// The value [`num`]`(v)` stands for, as a reader parses it back. +pub(crate) fn written(v: f64) -> f64 { + num(v).parse().unwrap_or(0.0) +} + +/// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not +/// finite). +pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) { + let places = places.min(9); + let scale = 10f64.powi(places as i32); + // Past where the scaling overflows, values have no fractional digits to round. let v = if !v.is_finite() { 0.0 - } else if (v * 10_000.0).is_finite() { - (v * 10_000.0).round() / 10_000.0 + } else if (v * scale).is_finite() { + (v * scale).round() / scale } else { v }; @@ -46,7 +59,7 @@ pub(crate) fn push_num(s: &mut String, v: f64) { return; } let start = s.len(); - let _ = write!(s, "{v:.4}"); + let _ = write!(s, "{v:.*}", places as usize); let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len(); s.truncate(start + keep); } @@ -291,7 +304,7 @@ pub(crate) fn inflate(data: &[u8], max: u64) -> Option> { #[cfg(test)] mod tests { - use super::{ascii85, ascii85_decode, num}; + use super::{ascii85, ascii85_decode, num, push_num_places}; #[test] fn ascii85_first_line_never_starts_with_percent() { @@ -308,4 +321,18 @@ mod tests { assert!(!s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.'), "{v}: got {s:?}"); } } + + #[test] + fn numbers_keep_the_decimals_asked_for() { + let places = |v: f64, n: u32| { + let mut s = String::new(); + push_num_places(&mut s, v, n); + s + }; + assert_eq!(num(0.123_456_78), "0.1235"); + assert_eq!(places(0.123_456_78, 7), "0.1234568"); + assert_eq!(places(f64::from(0.4f32), 7), "0.4"); + assert_eq!(places(2.5, 7), "2.5"); + assert_eq!(places(f64::NAN, 7), "0"); + } } diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 96205aa7d3..5cc90117d7 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -44,8 +44,12 @@ const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a Dev const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors 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 = + "gradients that repeat or reflect are written with at most 256 runs of their stops (at most 16384 stops); they pad past those"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGE_COUNT: usize = 360; +/// Decimals of a smooth shading's stitching function bounds (stop offsets, 0 to 1). +const BOUNDS_PLACES: u32 = 7; /// The most colorants a DeviceN color space may have (PostScript's limit). const MAX_DEVICE_N: usize = 32; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; @@ -940,6 +944,23 @@ 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); + // 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) { + if padded.capped { + self.warn(SPREAD_CAPPED); + } + let geom = padded.geom; + let paint = GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), ..g.clone() }; + return self.gradient(&paint, bounds, area); + } + // One whose levels over the area are not finite (a vector of no length): its stops + // repeated along the vector, padding past it. + if g.gradient.is_spread() && g.gradient.kind != GradientKind::Conic { + self.warn(SPREAD_CAPPED); + let paint = GradientPaint { gradient: g.gradient.unrolled().into_owned(), ..g.clone() }; + return self.gradient(&paint, bounds, area); + } let (space, stops) = self.gradient_stops(&g.gradient); if !stops.windows(2).any(|w| w[1].0 > w[0].0) { if let Some((_, c)) = stops.first() { @@ -951,7 +972,7 @@ impl<'a> Scene<'a> { let space = &space; let smooth = self.o.level == Level::Three && !self.o.compatible_gradients; match g.gradient.kind { - GradientKind::Conic => self.conic_steps(&geom, area, space, &stops), + GradientKind::Conic => self.conic_steps(&geom, area, space, &stops, &g.gradient), GradientKind::Diamond => self.diamond_quadrants(&geom, area, space, &stops, smooth), GradientKind::Radial => { let r = geom.length().max(1e-6); @@ -984,15 +1005,24 @@ impl<'a> Scene<'a> { /// A conic gradient as [`CONIC_WEDGE_COUNT`] solid wedges about its center covering `area` /// (PostScript has no conic shading), each running on to the seam under the next, so no /// hairline shows between neighbors ([`vectorcraft_color::GradientGeom::conic_wedges`]). - fn conic_steps(&mut self, geom: &vectorcraft_color::GradientGeom, area: Rect, space: &GradientSpace, stops: &[(f64, Vec)]) { + /// Stops of `g` that repeat or reflect get that many wedges for each time they run around. + fn conic_steps( + &mut self, + geom: &vectorcraft_color::GradientGeom, + area: Rect, + space: &GradientSpace, + stops: &[(f64, Vec)], + g: &vectorcraft_color::Gradient, + ) { self.warn(CONIC_WEDGES); + let spread = g.spreader(); let at = |t: f64| match stops.windows(2).find(|w| t <= w[1].0) { Some(w) if w[1].0 > w[0].0 => mix(&w[0].1, &w[1].1, ((t - w[0].0) / (w[1].0 - w[0].0)).clamp(0.0, 1.0) as f32), Some(w) => w[1].1.clone(), None => stops.last().map_or_else(Vec::new, |s| s.1.clone()), }; - for (pts, t) in geom.conic_wedges(area, CONIC_WEDGE_COUNT, true) { - self.set_comps(space, &at(t)); + for (pts, t) in geom.conic_wedges(area, g.conic_wedge_count(CONIC_WEDGE_COUNT), true) { + self.set_comps(space, &at(spread(t))); for (i, p) in pts.iter().enumerate() { push_nums(&mut self.body, &[p.x, p.y]); self.line(if i == 0 { "newpath moveto" } else { "lineto" }); @@ -1059,8 +1089,12 @@ impl<'a> Scene<'a> { f.push('\n'); } f.push_str("] /Bounds ["); - let bounds: Vec = many.iter().skip(1).map(|w| w[0].0).collect(); - push_nums(&mut f, &bounds); + // Seven decimals: a gradient's stops repeated hundreds or thousands of times + // (`Gradient::padded_over`) keep their places in each run. + for w in many.iter().skip(1) { + ps::push_num_places(&mut f, w[0].0, BOUNDS_PLACES); + f.push(' '); + } f.push_str("] /Encode ["); f.push_str(&"0 1 ".repeat(many.len())); f.push_str("] >>"); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index db9ae2837c..13bdbb80e2 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -244,6 +244,82 @@ fn diamond_gradients_are_four_linear_gradients_clipped_to_quadrants() { ); } +/// PostScript's shadings pad past their ends: a linear, radial or diamond gradient that repeats or +/// reflects is written with its stops repeated over the shape, a conic one as wedges that repeat +/// with the stops; read back from the PostScript, each looks as the canvas paints it, at Level 3 +/// and Level 2. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let c = Point::new(100.0, 100.0); + let around = GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(60.0, -25.0), aspect: 0.7, focal: None }; + let cases = [ + (GradientKind::Linear, GradientGeom { start: Point::new(70.0, 80.0), end: Point::new(150.0, 110.0), aspect: 1.0, focal: None }), + (GradientKind::Radial, around), + (GradientKind::Radial, GradientGeom { focal: Some(c + vectorcraft_geom::Vec2::new(-15.0, 12.0)), ..around }), + (GradientKind::Conic, around), + (GradientKind::Diamond, around), + ]; + for (kind, geom) in cases { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + 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 mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(kind, stops) }); + g.geom = Some(geom); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint, Paint::None, 0.0)]); + for o in [opts(&d), EpsOptions { level: Level::Two, ..opts(&d) }] { + let out = export(&d, &o, None, None).unwrap(); + let back = import_with(&out.bytes, false).unwrap().document; + let (a, b) = (vectorcraft_testkit::raster::render_artboard(&d), vectorcraft_testkit::raster::render_artboard(&back)); + let st = vectorcraft_testkit::raster::perceptual_diff(&a, &b, 12.0); + assert!( + (st.bad_pixels as f64) < 0.03 * st.pixels as f64, + "{kind:?} {spread:?} level {:?}: {} of {} pixels differ", + o.level, + st.bad_pixels, + st.pixels + ); + } + } + } +} + +/// A gradient that repeats with no stops (a file can hold one) is written as one that pads, to +/// EPS, legacy `.ai` and the `.ai` editing data. +#[test] +fn a_spread_gradient_without_stops_is_written() { + use vectorcraft_color::GradientSpread; + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(kind, vec![]) }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint.clone(), paint, 4.0)]); + export(&d, &opts(&d), None, None).unwrap(); + export_legacy_ai(&d, &LegacyAiOptions::default()).unwrap(); + crate::export_ai_editing(&d, &crate::AiEditingOptions::default()).unwrap(); + } +} + +/// A gradient repeated 256 times across the shape keeps each run's place: the smooth shading's +/// stitching bounds lie where the runs meet, to well under a run's thousandth. +#[test] +fn many_repeats_keep_their_places_in_a_smooth_shading() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + 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))]; + // A run of 0.4 pt across a shape 160 pt wide: capped at 256 runs. + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + g.geom = Some(GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(60.0, 100.0), aspect: 1.0, focal: None }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint, Paint::None, 0.0)]); + let page = page_of(&d, &opts(&d)); + let bounds: Vec = + page.split("/Bounds [").nth(1).and_then(|s| s.split(']').next()).unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + let runs = (bounds.len() + 1) as f64; + assert_eq!(runs, 256.0, "{bounds:?}"); + for b in bounds { + assert!((b * runs - (b * runs).round()).abs() < 1e-3, "bound {b} is {} runs along", b * runs); + } +} + #[test] fn strokes_keep_their_caps_joins_and_dashes() { let d = doc_with(|d| { @@ -976,7 +1052,7 @@ fn a_tiff_preview_sits_behind_the_binary_header() { } // Asked for but not rendered: the file has no preview, and says so. let out = export(&d, &EpsOptions { preview: Preview::TiffColor, ..opts(&d) }, None, None).unwrap(); - assert!(out.bytes.starts_with(b"%!PS") && out.warnings.iter().any(|w| w.contains("preview"))); + assert!(out.bytes.starts_with(b"%!PS") && out.setting_notes.iter().any(|w| w.contains("preview"))); } #[test] diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4e23ec0842..968cd720a0 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -504,11 +504,23 @@ pub fn load_file(bytes: &[u8]) -> Result { load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) } +/// [`load_file`] with a compressed file refused when its JSON, or the data after it (images, +/// profiles, the preview), unpacks to more than `max` bytes (at most [`MAX_JSON`] and +/// [`MAX_BLOBS`]), or, when it lacks the marker after its JSON, when all of it does. +pub fn load_file_capped(bytes: &[u8], max: u64) -> Result { + let file = unpack::Unpacked::within_capped(bytes, max.min(MAX_JSON), max.min(MAX_UNPACKED), max.min(MAX_BLOBS))?; + load_unpacked(file, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) +} + /// [`load_file`] reading at most `max_nodes` objects and buffering at most `max_values` values /// before objects' types, so a test can exercise the budgets without documents that large /// ([`vectorcraft_doc::node::MAX_READ_NODES`], [`vectorcraft_doc::node::MAX_BUFFERED_VALUES`]). fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result { - let file = unpack::Unpacked::new(bytes)?; + load_unpacked(unpack::Unpacked::new(bytes)?, max_nodes, max_values) +} + +/// The document of `file`, within the budgets of [`load_file_within`]. +fn load_unpacked(file: unpack::Unpacked<'_>, max_nodes: usize, max_values: usize) -> Result { // The JSON reader stops at 128 levels (a document's objects use three each), the paths read // at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the @@ -1186,6 +1198,30 @@ mod tests { assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data()); } + /// A capped read refuses a compressed file whose images unpack past the cap, before reading + /// them, and reads the same file under a cap it fits. + #[test] + fn a_capped_load_caps_the_data_after_the_json() { + use std::io::Write as _; + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + d.images.insert("k".into(), ImageBlob::new("image/png", vec![7; 1 << 16])); + let img = d.alloc_id(); + let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 0, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); + // The file gzipped whole, image and all, so the image unpacks from a few bytes. + let raw = save_with(&d, &SaveOptions::default()).unwrap(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + gz.write_all(&raw).unwrap(); + let bytes = gz.finish().unwrap(); + assert!(is_compressed(&bytes) && bytes.len() < 1 << 12, "{}", bytes.len()); + let json = unpack::Unpacked::new(&bytes).unwrap().json().len(); + assert!(json < 1 << 15, "{json}"); + assert!(matches!(load_file_capped(&bytes, (1 << 16) - 1), Err(FormatError::TooBig))); + assert_eq!(load_file_capped(&bytes, 3 << 15).unwrap().doc.images["k"].bytes.len(), 1 << 16); + assert_eq!(load_file(&bytes).unwrap().doc.images["k"].bytes.len(), 1 << 16); + } + /// #3587: objects are read within a budget. A compressed file of many small objects stays /// small on disk while they take far more memory once read, so past the budget it is refused /// with a message naming it, before the rest of the document is read. diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 1573fa0bc7..ec9623118c 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -28,6 +28,13 @@ pub(crate) type Entry = Option>>; pub(crate) struct Unpacked<'a> { json: Json<'a>, tail: Tail<'a>, + /// Most bytes the entries after the JSON may have together ([`MAX_BLOBS`] unless capped). + max_blobs: u64, + /// Most bytes unpacked to find them in a streamed file ([`MAX_WORK`] unless capped). + max_work: u64, + /// Must a streamed file's data after the JSON end within `max_blobs` bytes + /// ([`Self::within_capped`])? + capped: bool, } enum Json<'a> { @@ -68,9 +75,25 @@ impl<'a> Unpacked<'a> { /// a document several times its size, and the cap is what keeps that off the address space /// (#3587). fn within(bytes: &'a [u8], max_json: u64) -> Result { + let (json, tail) = Self::open(bytes, max_json, MAX_UNPACKED)?; + Ok(Self { json, tail, max_blobs: MAX_BLOBS, max_work: max_json.saturating_add(MAX_BLOBS).min(MAX_WORK), capped: false }) + } + + /// [`Self::within`], with a compressed file that lacks the marker after its JSON (unpacked + /// whole) capped at `max_whole` bytes instead of [`MAX_UNPACKED`], the entries after the JSON + /// at `max_blobs` bytes together instead of [`MAX_BLOBS`], and the bytes unpacked to find them + /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. A streamed file whose data + /// after the JSON unpacks to more than `max_blobs` bytes is refused when its entries are read. + pub(crate) fn within_capped(bytes: &'a [u8], max_json: u64, max_whole: u64, max_blobs: u64) -> Result { + let (json, tail) = Self::open(bytes, max_json, max_whole)?; + Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK), capped: true }) + } + + /// The JSON of `bytes` and where the data after it is, within `max_json` and `max_whole`. + fn open(bytes: &'a [u8], max_json: u64, max_whole: u64) -> Result<(Json<'a>, Tail<'a>), FormatError> { if !is_compressed(bytes) { let (json, tail) = split_tail(bytes); - return Ok(Self { json: Json::Borrowed(json), tail: Tail::Slice(tail) }); + return Ok((Json::Borrowed(json), Tail::Slice(tail))); } let mut dec = GzDecoder::new(bytes); let mut buf = Vec::new(); @@ -93,7 +116,7 @@ impl<'a> Unpacked<'a> { if buf.len() as u64 > max_json { return Err(FormatError::TooBig); } - return Ok(Self { json: Json::Owned(buf), tail: Tail::None }); + return Ok((Json::Owned(buf), Tail::None)); } // The marker where the writer puts it, right after the JSON's last byte: what is unpacked // so far reaches past it. @@ -104,17 +127,17 @@ impl<'a> Unpacked<'a> { let read = buf.split_off(end + BLOB_MAGIC.len()); buf.truncate(end); let at = (end + BLOB_MAGIC.len()) as u64; - return Ok(Self { json: Json::Owned(buf), tail: Tail::Stream { file: bytes, at, read, rest: Box::new(dec) } }); + return Ok((Json::Owned(buf), Tail::Stream { file: bytes, at, read, rest: Box::new(dec) })); } // Anything else (hand-made or damaged files) is unpacked whole and split as an // uncompressed file is. - dec.take(MAX_UNPACKED + 1 - (buf.len() as u64).min(MAX_UNPACKED)).read_to_end(&mut buf).map_err(damaged)?; - if buf.len() as u64 > MAX_UNPACKED { + dec.take(max_whole + 1 - (buf.len() as u64).min(max_whole)).read_to_end(&mut buf).map_err(damaged)?; + if buf.len() as u64 > max_whole { return Err(FormatError::TooBig); } let (json, tail) = split_tail(&buf); let (json, tail) = (json.len(), buf.len() - tail.len()); - Ok(Self { json: Json::Whole { buf, json }, tail: Tail::Whole(tail) }) + Ok((Json::Whole { buf, json }, Tail::Whole(tail))) } /// The JSON text. @@ -133,13 +156,16 @@ impl<'a> Unpacked<'a> { /// of an uncompressed file larger together than both [`MAX_BLOBS`] and the data, or the /// distinct entries found larger together than the data they are in (overlapping entries /// would copy the same bytes once each, #3574). Entries are read in the order of their - /// offsets, each straight into its own buffer. + /// offsets, each straight into its own buffer. A capped file ([`Self::within_capped`]) takes + /// its own totals in place of [`MAX_BLOBS`] and [`MAX_WORK`]. pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result, FormatError> { - self.entries_within(wanted, MAX_WORK) + let work = self.max_work; + self.entries_within(wanted, work) } /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { + let (max_blobs, capped) = (self.max_blobs, self.capped); let mut out: Vec = vec![None; wanted.len()]; // Data in memory is read through the crate's `Entries`: entries naming the same bytes // share them, and the bytes copied stay within the data. @@ -155,11 +181,11 @@ impl<'a> Unpacked<'a> { }; match self.tail { Tail::Slice(tail) => { - within(wanted, u64::MAX, MAX_BLOBS.max(tail.len() as u64))?; + within(wanted, u64::MAX, max_blobs.max(tail.len() as u64))?; in_memory(tail, &mut out)?; } Tail::Whole(at) => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; let buf = match &self.json { Json::Whole { buf, .. } => buf.as_slice(), _ => &[], @@ -168,7 +194,7 @@ impl<'a> Unpacked<'a> { } Tail::None => in_memory(&[], &mut out)?, Tail::Stream { file, at, read, rest } => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; drop(self.json); // The entries read so far, by `[offset, length]`, and the bytes copied for them. let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); @@ -193,9 +219,10 @@ impl<'a> Unpacked<'a> { } done.insert(w, slot.clone()); } - // To the end, so the stream's checksum is verified as before. - s.left = MAX_BLOBS; - s.skip(MAX_BLOBS)?; + // To the end, so the stream's checksum is verified as before. A capped file's data + // must end within its cap from its start: one byte more is over the budget. + s.left = if capped { max_blobs.checked_sub(s.pos).ok_or(FormatError::TooBig)? } else { max_blobs }; + s.skip(if capped { s.left.saturating_add(1) } else { max_blobs })?; } } Ok(out) @@ -317,6 +344,16 @@ mod tests { // An offset past the data is still just missing, and the default budget reads all of it. assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); + // A capped file finds its entries within its own caps: a small entry far into the data, + // entries larger than the cap together, or data that goes on past the cap after the + // entries, are refused. + let capped = |cap: u64| Unpacked::within_capped(&file, 64, MAX_UNPACKED, cap).unwrap(); + assert_eq!(capped(4096).entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); + assert!(matches!(capped(4095).entries(&[[0, 16]]), Err(FormatError::TooBig))); + assert!(matches!(capped(4095).entries(&[[4095, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[4000, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[0, 17]]), Err(FormatError::TooBig))); + assert_eq!(Unpacked::new(&file).unwrap().entries(&[[4000, 1]]).unwrap(), vec![Some(Arc::new(vec![tail[4000]]))]); } /// #3587: a compressed file's JSON is capped wherever the cap runs, so a small gzip that diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b869f511c1..044d4b0098 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -404,7 +404,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( @@ -594,7 +594,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel", "Show Panel", "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel", ), ( "window.panel.layout", @@ -652,6 +652,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", ), + ( + "learn.locateTool", + "Show Tool Location", + "", + "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", @@ -801,7 +807,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.sampleScreenColor", "Sample Color from Screen", "", - "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", + "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; Linux through the XDG desktop portal's PickColor; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; a cancel (Escape on macOS and in browsers) leaves the colors as they were. ui.inspect reports `screenSampler: true` until it answers; on Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request another way end the wait with a status message. An error without a sampler, while one is open, or with `picker` and no Color Picker open", ), ( "ui.newBrush", @@ -1371,25 +1377,25 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.action.rename", "Rename Action", "", - "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}", + "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, an empty name, or a name that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a name that leaves the file as large or makes it smaller is never refused) is an error and renames nothing → {name}", ), ( "ui.action.stepsIncluded", "Include Action Steps", "", - "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}", + "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing. An exclusion that would give the set's action set file more than 8 MiB, counting the steps the recording in progress adds to the set, is an error and changes nothing; a change that leaves the file as large or makes it smaller is never refused → {set, action?, step?, included}", ), ( "ui.action.new", "New Action", "", - "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}", + "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}", ), ( "ui.action.options", "Action Options", "", - "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}", + "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, a key or color that is not one of these, or a change that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a change that leaves the file as large or makes it smaller is never refused) is an error and changes nothing → {name, key, color}", ), ( "ui.action.play", @@ -1407,13 +1413,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 or an existing recording is an error and changes nothing → {set, name}", + "{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}", ), ( "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. 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, 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", @@ -1455,7 +1461,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.actionSet.load", "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( "ui.actionSet.delete", @@ -1467,7 +1473,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.actionSet.duplicate", "Duplicate Action or Action Set", "", - "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}", + "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action, or a copy that would give its set more than 1,000 actions or an action set file larger than 8 MiB, is an error and copies nothing → {set, action?}", ), ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), ( diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 343b422093..ac5cf183cd 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -415,13 +415,13 @@ impl<'a> Scene<'a> { field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() } else { let geom = g.resolve(bounds); - let sample = g.gradient.sampler(); + let (sample, spread) = (g.gradient.sampler(), g.gradient.spreader()); let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows)); (0..usize::from(rows) * usize::from(cols)) .flat_map(|i| { let (x, y) = (i % usize::from(cols), i / usize::from(cols)); let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch); - let t = geom.param_at(g.gradient.kind, p); + let t = spread(geom.param_at(g.gradient.kind, p)); let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; let (c, a) = sample(t as f32); c.to_rgba8(a) diff --git a/crates/pathops/src/addons/pathtools/crossings.rs b/crates/pathops/src/addons/pathtools/crossings.rs index e1d96ed0fd..d216a2f4b3 100644 --- a/crates/pathops/src/addons/pathtools/crossings.rs +++ b/crates/pathops/src/addons/pathtools/crossings.rs @@ -182,7 +182,19 @@ pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet(); + // How many anchors were added before each anchor, counted in one pass for all of them. + let mut before = vec![0usize; n + 1]; + for (&seg, ts) in &inner { + if let Some(b) = before.get_mut(seg + 1) { + *b += ts.len(); + } + } + let mut sum = 0; + for b in &mut before { + sum += *b; + *b = sum; + } + let added_before = |j: usize| before.get(j).copied().unwrap_or(0); let mut marks: BTreeSet = at_anchor.into_iter().map(|j| j + added_before(j)).collect(); for (&seg, ts) in &inner { let base = seg + added_before(seg); diff --git a/crates/pathops/src/addons/stencil.rs b/crates/pathops/src/addons/stencil.rs index 437433e40c..a5412fa328 100644 --- a/crates/pathops/src/addons/stencil.rs +++ b/crates/pathops/src/addons/stencil.rs @@ -146,7 +146,7 @@ fn choose(contours: &[Contour], spec: BridgeSpec, work: usize) -> Result<(Vec
0 { return Err(PathOpsError::TooComplex); } picker.spread(spec.count); diff --git a/crates/pathops/src/addons/stencil/tests.rs b/crates/pathops/src/addons/stencil/tests.rs index 48d2f8148e..93ea92cdde 100644 --- a/crates/pathops/src/addons/stencil/tests.rs +++ b/crates/pathops/src/addons/stencil/tests.rs @@ -210,6 +210,37 @@ fn bridges_found_before_the_budget_runs_out_are_kept() { assert!(matches!(choose(&found, spec(4.0, 1, BridgeDirection::Vertical), least - 1), Err(PathOpsError::TooComplex))); } +#[test] +fn joined_art_is_kept_when_lines_in_one_direction_run_out() { + // A ring whose thin left bar has edges of many short zigzag segments: lines across it in the + // horizontal direction look at many more segments than vertical lines across the top bar. + let zigzag = |x: f64, from: f64, to: f64, out: &mut Vec| { + let n = ((to - from).abs() / 0.05) as usize; + out.extend((1..n).map(|i| Point::new(x + if i % 2 == 1 { 0.02 } else { 0.0 }, from + (to - from) * i as f64 / n as f64))); + }; + let mut outer = vec![Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)]; + zigzag(0.0, 100.0, 0.0, &mut outer); + let mut hole = vec![Point::new(10.0, 20.0), Point::new(170.0, 20.0), Point::new(170.0, 80.0), Point::new(10.0, 80.0)]; + zigzag(9.98, 80.0, 20.0, &mut hole); + let art = PathData::new(vec![SubPath::polyline(&outer, true), SubPath::polyline(&hole, true)]); + let found = contours(&art, 4.0).unwrap(); + let shortest = spec(4.0, 1, BridgeDirection::Shortest); + let views = (0..180).step_by(SHORTEST_STEP_DEG).count(); + // A share of the work that runs out in the horizontal direction while another direction joins + // the island. + let share = (1..5000) + .find(|&budget| { + let all: Vec = + (0..180).step_by(SHORTEST_STEP_DEG).map(|d| View::new(&found, (d as f64).to_radians(), 4.0, budget).unwrap()).collect(); + let mut picker = Picker::new(&found, &all, 4.0); + picker.connect(); + all.iter().any(View::exhausted) && picker.unjoined() == 0 + }) + .expect("a share of the work that runs out with the island joined"); + let (bridges, unjoined) = choose(&found, shortest, share * views).unwrap(); + assert_eq!((bridges.len(), unjoined), (1, 0)); +} + #[test] fn curves_are_followed_within_a_twentieth_of_the_width() { let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.001)); diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 788cacea3a..eabba52d9e 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -7,8 +7,9 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { export_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export`], also returning warnings: options accepted but not applied yet (see -/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. +/// Like [`export`], also returning warnings, features approximated or dropped, and notes on the +/// options: options accepted but not applied yet (see [`crate::PdfSettings::warnings`]) or applied +/// another way. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); @@ -63,9 +64,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty()); let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). - let mut warnings = set.warnings(); + let mut warnings = vec![]; + let mut settings = set.warnings(); if raised.is_some() { - warnings.push(if opts.settings.compatibility == Compatibility::Pdf14 { + settings.push(if opts.settings.compatibility == Compatibility::Pdf14 { "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6".into() } else { "PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into() @@ -75,7 +77,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha if let Some(native) = native { w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time))); } else if set.preserve_editing { - warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); + settings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); } let mut ex = Exporter::new(doc, set); @@ -85,7 +87,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // A knockout page group draws the layers' objects one by one, not layer by layer. ex.layers = set.writes_layers() && !doc.page_knockout; if set.writes_layers() && doc.page_knockout { - warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); + settings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); } ex.overprint = set.advanced.overprint == crate::Overprint::Preserve; let bleed = set.bleed_of(doc); @@ -142,7 +144,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible); w.absorb(ex); - let (bytes, more) = match w.finish(left_out, display_doc_title) { + let (bytes, more, catalog) = match w.finish(left_out, display_doc_title) { Ok(done) => done, Err(e) => return Err(writer_error(doc, opts, left_out, &long, e)), }; @@ -155,11 +157,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // The writer's metadata has no copyright: it is added to the XMP packet. let bytes = crate::post::rights(bytes, &doc.metadata)?; warnings.extend(more); + settings.extend(catalog); let bytes = crate::post::user_units(bytes, &units)?; // A PDF 1.5 file written as PDF 1.6 keeps the encryption PDF 1.5 has (128-bit RC4). - let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; + let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut settings)?; warnings.dedup(); - Ok(ExportReport { bytes, warnings }) + settings.dedup(); + Ok(ExportReport { bytes, warnings, setting_notes: settings }) } /// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 90b7ae5248..2d4113a1fa 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -434,8 +434,9 @@ impl Exporter<'_> { /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of spot /// inks ([`Self::perceptual_spot`]), a conic gradient that can't be a sweep shading (in - /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops), or a diamond gradient - /// (four linear gradients, [`Self::diamond`])? + /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops), a diamond gradient + /// (four linear gradients, [`Self::diamond`]), or a linear or radial gradient that repeats or + /// reflects (its stops repeated over the area, [`Self::padded`])? pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool { match p { Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), @@ -444,6 +445,7 @@ impl Exporter<'_> { (g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill)) || (g.gradient.kind == GradientKind::Conic && sweep_unwritable()) || g.gradient.kind == GradientKind::Diamond + || (g.gradient.is_spread() && matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial)) } _ => false, } @@ -452,7 +454,8 @@ impl Exporter<'_> { /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), a diamond - /// gradient's four linear gradients, or an image of a gradient drawn as pixels, placed on + /// gradient's four linear gradients, a repeating or reflecting linear or radial gradient's + /// stops repeated over `area`, or an image of a gradient drawn as pixels, placed on /// `bounds` (the painted object's box, as on the canvas), clipped to it. False (nothing drawn) /// for other paints. #[allow(clippy::too_many_arguments)] @@ -504,7 +507,9 @@ impl Exporter<'_> { } Paint::Gradient(g) => { let pixels = g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill); - if pixels || g.gradient.kind != GradientKind::Diamond || !self.diamond(s, g, bounds, area) { + // A linear or radial gradient that repeats or reflects is drawn by `padded`. + let drawn = !pixels && self.padded(s, g, bounds, area); + if !drawn && (pixels || g.gradient.kind != GradientKind::Diamond || !self.diamond(s, g, bounds, area)) { self.field_image(s, g, bounds, area, fill); } } diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 38ab52f182..acb96f1713 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -236,6 +236,24 @@ impl Exporter<'_> { Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); + // A linear or radial gradient that repeats or reflects, where it isn't an area + // paint ([`Self::padded`]): its stops repeated over `bounds`, padding past them. + if matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial) && g.gradient.is_spread() { + let paint = match g.gradient.padded_over(&geom, bounds) { + Some(padded) => { + self.warn_capped(padded.capped); + padded_paint(g, padded) + } + // Levels over `bounds` that are not finite (a vector of no length): the + // stops repeated along the vector, padding past it. + None => { + self.warn_capped(true); + let gradient = g.gradient.unrolled().into_owned(); + Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient, ..(**g).clone() })) + } + }; + return self.paint_in(s, &paint, bounds, space); + } let spot = match g.gradient.kind { GradientKind::Freeform => None, _ => self.perceptual_spot_stops(&g.gradient).or_else(|| self.spot_stops(&g.gradient)), @@ -318,20 +336,27 @@ impl Exporter<'_> { } // A function-based shading (ISO 32000-1, 8.7.4.5.2) whose calculator function // turns the angle about the origin into the ramp, once around (0 to 360 - // degrees), carried onto the extent ellipse by its frame. + // degrees), carried onto the extent ellipse by its frame. Stops that repeat + // or reflect `n` times run over 360/`n` degrees, the function repeating or + // reflecting them past it. GradientKind::Conic => { let frame = geom.conic_frame(); if !writable(s, space * frame, Rect::new(-1.0, -1.0, 1.0, 1.0)) { return None; } + let (end_angle, spread_method) = match g.gradient.spread { + _ if !g.gradient.is_spread() => (360.0, SpreadMethod::Pad), + vectorcraft_color::GradientSpread::Reflect => (360.0 / g.gradient.repeat_count() as f32, SpreadMethod::Reflect), + _ => (360.0 / g.gradient.repeat_count() as f32, SpreadMethod::Repeat), + }; Some( SweepGradient { cx: 0.0, cy: 0.0, start_angle: 0.0, - end_angle: 360.0, + end_angle, transform: xf(space * frame), - spread_method: SpreadMethod::Pad, + spread_method, stops, anti_alias: false, } @@ -484,15 +509,27 @@ impl Exporter<'_> { /// linear gradients, one for each quadrant of its frame, each equal to the diamond there (PDF /// has no diamond shading; [`vectorcraft_color::GradientGeom::diamond_quadrants`]). The /// quadrants overlap so no hairline shows between them, which needs opaque stops. False - /// (nothing drawn) for a diamond with translucent stops or without a frame, which + /// (nothing drawn) for a diamond with translucent stops or without a frame, for one with a + /// quadrant whose shading or outline PDF can't hold, and for a Perceptual one of spot inks + /// whose ink curve has too many tints once its stops are repeated, which /// [`Self::field_image`] draws instead. pub(super) fn diamond(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) -> bool { if g.gradient.stops.iter().any(|st| st.opacity < 1.0) { return false; } - let Some(quadrants) = g.resolve(bounds).diamond_quadrants(area.abs()) else { return false }; - self.warn("diamond gradients are exported as four linear gradients"); - let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..g.gradient.clone() }; + // A diamond that repeats or reflects: its stops repeated over the levels the area reaches + // from the center, then split into quadrants, each of which runs those levels. + let (diamond, geom, capped) = match g.gradient.padded_over(&g.resolve(bounds), area.abs()) { + Some(padded) => (padded.gradient, padded.geom, padded.capped), + None => (g.gradient.clone(), g.resolve(bounds), false), + }; + // A Perceptual one of spot inks whose ink curve no longer fits with its stops repeated. + if !diamond.method.is_classic() && !diamond.fits_ink_tints() { + return false; + } + let Some(quadrants) = geom.diamond_quadrants(area.abs()) else { return false }; + let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..diamond }; + let mut drawn = Vec::with_capacity(4); for (outline, geom) in quadrants { let linear = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient: gradient.clone(), geom: Some(geom), ..g.clone() })); let quadrant = kurbo::BezPath::from_vec( @@ -503,16 +540,61 @@ impl Exporter<'_> { .chain([kurbo::PathEl::ClosePath]) .collect(), ); - if let (Some(paint), Some(path)) = (self.paint(s, &linear, bounds), to_path(s, &quadrant)) { - s.set_stroke(None); - s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); - s.draw_path(&path); - s.set_fill(None); - } + let (Some(paint), Some(path)) = (self.paint(s, &linear, bounds), to_path(s, &quadrant)) else { return false }; + drawn.push((paint, path)); + } + self.warn("diamond gradients are exported as four linear gradients"); + self.warn_capped(capped); + for (paint, path) in drawn { + s.set_stroke(None); + s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + s.set_fill(None); } true } + /// Draw linear or radial gradient `g`, placed on `bounds`, that repeats or reflects, over + /// `area` (inside the current clip): PDF's axial and radial shadings pad past their ends, so + /// its stops are repeated as many times as the area needs + /// ([`vectorcraft_color::Gradient::padded_over`]). False (nothing drawn) for another gradient, + /// for one whose shading or area PDF can't hold, and for a Perceptual one of spot inks whose + /// ink curve has too many tints once repeated, which [`Self::field_image`] draws instead. + pub(super) fn padded(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) -> bool { + if !matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial) { + return false; + } + let Some(padded) = g.gradient.padded_over(&g.resolve(bounds), area.abs()) else { return false }; + // A Perceptual gradient of spot inks whose ink curve no longer fits once its stops are + // repeated is an image, as one with too many stops is. + if !g.gradient.method.is_classic() && !padded.gradient.fits_ink_tints() { + return false; + } + let capped = padded.capped; + let paint = padded_paint(g, padded); + let (Some(paint), Some(path)) = (self.paint(s, &paint, bounds), to_path(s, &kurbo::Shape::to_path(&area.abs(), 0.1))) else { + return false; + }; + self.warn_capped(capped); + s.set_stroke(None); + s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + s.set_fill(None); + true + } + + /// Warn when a gradient that repeats or reflects needed more runs of its stops than were + /// written. + fn warn_capped(&mut self, capped: bool) { + if capped { + self.warn(format!( + "gradients that repeat or reflect are exported with at most {} runs of their stops (at most {} stops); they pad past those", + vectorcraft_color::gradient::MAX_SPREAD_RUNS, + vectorcraft_color::gradient::MAX_SPREAD_STOPS + )); + } + } + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most @@ -588,6 +670,12 @@ impl Exporter<'_> { } } +/// `g` written as `padded` gives it: its stops repeated, placed there. +fn padded_paint(g: &vectorcraft_color::GradientPaint, padded: vectorcraft_color::gradient::Padded) -> Paint { + let geom = padded.geom; + Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), ..g.clone() })) +} + /// Is `c` paper white: no ink in CMYK or gray, white in RGB or Lab? fn paper(c: &Color) -> bool { match *c { diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 027025ad2c..9f6d9d6906 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -26,6 +26,10 @@ pub(crate) struct Writer { images: Vec<(String, Image)>, } +/// What [`Writer::finish`] gives: the file, the warnings of its drawing, and the notes on the +/// output intent asked for ([`crate::ExportReport::setting_notes`]). +pub(crate) type Finished = (Vec, Vec, Vec); + /// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), /// which [`Self::view`] maps onto the page. pub(crate) struct Sheet<'a> { @@ -252,10 +256,11 @@ impl Writer { } /// The finished file (with the output intent and Trapped entries, and checked against its - /// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK + /// standard), the warnings of its drawing, and the notes on the output intent asked for + /// ([`crate::ExportReport::setting_notes`]). PDF/X-1a spot colours keep their CMYK /// alternates. When the writer couldn't decode one of the images, its key is added to /// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned. - pub(crate) fn finish(mut self, left_out: &mut HashSet, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { + pub(crate) fn finish(mut self, left_out: &mut HashSet, display_doc_title: bool) -> Result { let images = std::mem::take(&mut self.images); let mut bytes = self.pdf.finish().map_err(|e| { if let write::WriteError::Serialization(k) = &e @@ -270,10 +275,9 @@ impl Writer { } let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; - let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?; - self.warnings.extend(more); + let (bytes, settings) = self.out.write_catalog(bytes, display_doc_title)?; self.warnings.extend(self.rgb_in_cmyk.warning()); - Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) + Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings, settings)) } } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index ffc185a575..d978680d67 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -35,6 +35,7 @@ mod builder; mod cid; mod device; mod entry; +mod reshaped; mod state; #[cfg(test)] @@ -42,8 +43,8 @@ pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical}; #[cfg(any(test, feature = "read-count"))] pub use entry::imports_of; pub use entry::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(test)] pub(crate) use entry::{TEST_MAX_OBJECTS, import_with_reader_options, read}; diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 945c082a26..6d47168716 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -1,9 +1,10 @@ use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical}; use super::entry::{ - MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, + MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, RESHAPED_TEXT_NOTE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, max_objects, }; -use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; +use super::reshaped; +use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget, FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint, @@ -22,6 +23,7 @@ impl<'p> Builder<'p> { page: Rect::ZERO, glyphs: None, text: None, + text_glyphs: vec![], text_as, actual: Default::default(), images: HashMap::new(), @@ -101,6 +103,7 @@ impl<'p> Builder<'p> { self.blend = BlendMode::Normal; self.glyphs = None; self.text = None; + self.text_glyphs.clear(); self.mask = None; self.masked.clear(); self.marked.clear(); @@ -376,18 +379,56 @@ impl<'p> Builder<'p> { } pub(super) fn flush_text(&mut self) { - let Some(mut line) = self.text.take() else { return }; + if self.end_text() { + self.flush_glyphs(); + } + } + + /// Finish the open line of type. `true`: it was kept as outlines instead ([`reshaped`]), which + /// are left open as the glyph run for an outline drawn next to join. + pub(super) fn end_text(&mut self) -> bool { + let glyphs = std::mem::take(&mut self.text_glyphs); + 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 mut outlined = false; if let Some((t, opacity, facts)) = line.finish() { - let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); - n.opacity = opacity; - self.lines.insert(n.id, facts); - self.push_node(n); + if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) { + outlined = true; + for g in glyphs { + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == g.paint + && r.opacity == g.opacity + && r.overprint == g.overprint + && r.stroke.is_some() == g.stroke.is_some() + && r.stroke.as_ref().map(|s| s.line_width) == g.stroke.as_ref().map(|s| s.line_width) + && (r.scale - g.scale).abs() < 1e-9 + }); + match &mut self.glyphs { + Some(r) if same => r.path.extend(g.path.iter()), + _ => { + self.flush_glyphs(); + self.glyphs = Some(g); + } + } + } + self.warn(RESHAPED_TEXT_NOTE); + } else { + let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + n.opacity = opacity; + self.lines.insert(n.id, facts); + self.push_node(n); + } + } + if outlined && !held.is_empty() { + self.flush_glyphs(); } // The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke). for n in held { self.push_node(n); } + outlined } /// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph @@ -737,6 +778,27 @@ impl<'p> Builder<'p> { n } + /// Glyph `o` of the open line of type, placed by `m` and drawn with `drawn` (its paint, and its + /// stroke when it is stroked), as the outline it is kept as if the line is + /// ([`Self::end_text`]). Nothing for a glyph that shows nothing. + fn keep_glyph( + &mut self, + o: &craft_pdf::interpret::font::OutlineGlyph, + m: Affine, + drawn: Option<(Paint, Option)>, + opacity: f32, + scale: f64, + overprint: bool, + ) { + let Some((paint, stroke)) = drawn else { return }; + let mut path = o.outline(); + if path.elements().is_empty() { + return; + } + path.apply_affine(m); + self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint }); + } + /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, /// type with neither fill nor stroke. @@ -794,8 +856,11 @@ impl<'p> Builder<'p> { return false; } let width = o.advance_width().filter(|w| w.is_finite()); - // Set vertically (WMode 1): no horizontal advance. - let upright = width.is_some_and(|w| w.abs() <= 1.0); + // Set vertically (WMode 1): no horizontal advance. Not in a font the file says is written + // horizontally: there a glyph without an advance is a mark over the letter before it (a + // vowel sign, an anusvara), which stays on its line (#5235). + let horizontal = self.fonts.get(&key).is_some_and(|f| f.vertical.is_none()); + let upright = !horizontal && width.is_some_and(|w| w.abs() <= 1.0); let mut info = self.font_name(key, o); // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which // style of the installed family it is, so the name decides. @@ -841,6 +906,8 @@ impl<'p> Builder<'p> { } None => (None, 1.0), }; + // What the glyph is drawn with as an outline, should its line be kept so. + let drawn = paint.clone().map(|p| (p, stroke.cloned())); let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); let look = Look { font: key, @@ -879,6 +946,7 @@ impl<'p> Builder<'p> { && look.fill.is_none() && line.stroke_last(key, at, s.clone(), overprint) { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); return true; } if !self.text.as_mut().is_some_and(place) { @@ -889,6 +957,9 @@ impl<'p> Builder<'p> { } // Its ink: a stroke the file draws as outlines of the glyphs is told by it. let mut ink = o.outline(); + if top.is_none() { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); + } if let Some(line) = &mut self.text && !ink.elements().is_empty() { diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index c21e51dbb5..b39905d6e0 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -191,7 +191,8 @@ impl<'a> Device<'a> for Builder<'_> { if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { return; } - self.flush_text(); + // A line kept as outlines leaves them open: this glyph joins them. + self.end_text(); let mut bp = o.outline(); bp.apply_affine(transform * glyph_transform); let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true)); diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index 38f22689a8..8503b4fb20 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -171,14 +171,22 @@ pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's /// Warning: glyphs that differ from the installed font of their name were kept as outlines. pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines"; +/// Warning: type in a script whose letters reorder and join (Indic scripts) that, set again from +/// its characters, wouldn't draw the glyphs where the file drew them was kept as outlines (#5235). +pub const RESHAPED_TEXT_NOTE: &str = + "text that would not look as it does in the file if set as type (a script whose letters join) was kept as outlines"; + /// The end of the warning that glyphs whose characters the file doesn't name were kept as /// outlines, after "text in" and the font's name. pub const UNNAMED_CHARACTERS_NOTE: &str = "whose characters the file doesn't name (U+FFFD) was kept as outlines"; /// Is `note` one of the warnings that glyphs were kept as outlines ([`OUTLINED_TEXT_NOTE`], -/// [`UNMATCHED_GLYPHS_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? +/// [`UNMATCHED_GLYPHS_NOTE`], [`RESHAPED_TEXT_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? pub fn is_outlines_note(note: &str) -> bool { - note == OUTLINED_TEXT_NOTE || note == UNMATCHED_GLYPHS_NOTE || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) + note == OUTLINED_TEXT_NOTE + || note == UNMATCHED_GLYPHS_NOTE + || note == RESHAPED_TEXT_NOTE + || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) } /// Is `n` glyphs kept as outlines (a path the import names ``)? diff --git a/crates/pdf/src/import/reshaped.rs b/crates/pdf/src/import/reshaped.rs new file mode 100644 index 0000000000..77d70dfc1c --- /dev/null +++ b/crates/pdf/src/import/reshaped.rs @@ -0,0 +1,53 @@ +//! Type in a script whose letters reorder and join as they are set (#5235). A PDF draws such text +//! as glyphs already shaped: a vowel sign before the consonant it follows in reading order, a +//! conjunct as one glyph that the file may not name, a mark placed over its base. Read glyph by +//! glyph, the characters don't always make the text that was set, and type made from them draws +//! other glyphs in other places. A line of such type is therefore set again and compared with what +//! the file drew; one that would look different keeps the file's glyphs as outlines. + +use kurbo::{Rect, Shape}; +use vectorcraft_doc::TextObject; + +/// Does `text` have a character of a script whose letters reorder and join into conjuncts: the +/// Brahmic scripts of U+0900 to U+0DFF (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, +/// Telugu, Kannada, Malayalam, Sinhala)? +pub(super) fn reshapes(text: &str) -> bool { + text.chars().any(|c| ('\u{0900}'..='\u{0DFF}').contains(&c)) +} + +/// Would type `t` draw its glyphs where the file drew them (`ink`: each glyph's ink box in +/// document space)? As many glyphs with ink, each within 3 % of the type's size of one of the +/// file's. +pub(super) fn draws_as(t: &TextObject, ink: &[Rect]) -> bool { + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let mut set: Vec = + laid.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| (t.xf * g.outline.clone()).bounding_box()).collect(); + if set.len() != ink.len() { + return false; + } + let size = t.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + let tol = size * 0.03; + if !tol.is_finite() || set.iter().chain(ink).any(|r| !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())) { + return false; + } + set.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + let mut used = vec![false; set.len()]; + // Each of the file's glyphs takes one the type draws in its place: looked for among those + // whose left edge is as near. + ink.iter().all(|r| { + let from = set.partition_point(|s| s.x0 < r.x0 - tol); + let found = set + .iter() + .zip(used.iter_mut()) + .skip(from) + .take_while(|(s, _)| s.x0 <= r.x0 + tol) + .find(|(s, used)| !**used && (s.y0 - r.y0).abs() <= tol && (s.x1 - r.x1).abs() <= tol && (s.y1 - r.y1).abs() <= tol); + match found { + Some((_, used)) => { + *used = true; + true + } + None => false, + } + }) +} diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 419952b017..b74cbf5dab 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -81,6 +81,10 @@ pub(super) struct Builder<'p> { pub(super) page: Rect, pub(super) glyphs: 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 + /// ([`super::reshaped`]). + pub(super) text_glyphs: Vec, pub(super) text_as: TextAs, pub(super) actual: super::actual_text::ActualText, pub(super) images: HashMap, diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index 2cafbf010a..2a734174ce 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -1,5 +1,6 @@ //! Shadings: axial and radial ones become gradients (their stops sampled from the shading -//! function in its own colour model; an end that doesn't extend clips the painted area), a +//! function in its own color model, sharp steps between colors kept where they are; an end that +//! doesn't extend clips the painted area), a //! function-based one whose color changes along one axis a linear gradient, one whose color //! turns around the origin of its space a conic gradient, and one whose color is the same along //! each square about that origin a diamond gradient (else its average color; all of them @@ -41,13 +42,21 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components(); Some(Color::rgb(round3(r), round3(g), round3(b))) }; - // Sample densely, then drop samples that linear interpolation reproduces. + // Sample densely, halving each span between samples where its middle strays from the line + // between them (so a sharp step between two colors, as repeated stops make, is found to + // within [`EDGE_DEPTH`] halvings), then drop samples that linear interpolation reproduces. const N: usize = 64; let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1); for i in 0..=N { let t = i as f32 / N as f32; - pts.push((t, sample(t)?.to_rgb())); - // An ink that paints only at samples dropped below is still a spot swatch. + let rgb = sample(t)?.to_rgb(); + if let Some(&(t0, c0)) = pts.last() { + refine(&sample, (t0, c0), (t, rgb), 0, &mut pts); + } + pts.push((t, rgb)); + } + // An ink that paints only at samples dropped below is still a spot swatch. + for &(t, _) in &pts { if let Some(v) = at(t) { colors.mark_inks(space, &v); } @@ -92,6 +101,37 @@ fn shading_stop(colors: &mut Colors<'_>, space: Space, comps: Option<&[f32]>, of } } +/// How many times [`refine`] halves a span of an axial or radial shading's samples: to 1/64 × +/// 2^-14 of the axis. +const EDGE_DEPTH: u32 = 14; +/// The most samples of one axial or radial shading [`refine`] takes. +const MAX_EDGE_SAMPLES: usize = 16_384; +/// Where in a span [`refine`] looks a second time when the span's middle lies on the line: at +/// the golden section, which no whole number of repeated steps across the span puts on the same +/// place in a step as the ends. +const SECOND_LOOK: f32 = 0.381_966; + +/// Samples of `sample` between `a` and `b` (offset, RGB), pushed onto `out` in order: the span's +/// middle where it, or the point [`SECOND_LOOK`] along the span, strays from the line between +/// them by more than 1.5 levels, and the samples of each half the same way, at most +/// [`EDGE_DEPTH`] halvings deep and [`MAX_EDGE_SAMPLES`] in all. The second look finds steps that +/// repeat a whole number of times across the span, which put its ends and its middle at the same +/// place in a step. +fn refine(sample: &dyn Fn(f32) -> Option, a: (f32, [f32; 3]), b: (f32, [f32; 3]), depth: u32, out: &mut Vec<(f32, [f32; 3])>) { + if depth >= EDGE_DEPTH || out.len() >= MAX_EDGE_SAMPLES { + return; + } + let on_line = |u: f32, c: [f32; 3]| (0..3).all(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - c[k]).abs() <= 1.5 / 255.0); + let t = (a.0 + b.0) / 2.0; + let Some(c) = sample(t).map(|c| c.to_rgb()) else { return }; + if on_line(0.5, c) && sample(a.0 + (b.0 - a.0) * SECOND_LOOK).is_none_or(|p| on_line(SECOND_LOOK, p.to_rgb())) { + return; + } + refine(sample, a, (t, c), depth + 1, out); + out.push((t, c)); + refine(sample, (t, c), b, depth + 1, out); +} + /// The samples of `pts` (offset, RGB; at least one) a gradient keeps as stops: the first, the /// last, and each one that linear interpolation between its neighbouring stops doesn't reproduce. fn linear_keep(pts: &[(f32, [f32; 3])]) -> Vec { diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index b7163c0e58..200ec93673 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -269,6 +269,12 @@ impl TextLine { } } + /// Horizontal type whose characters are all read glyph by glyph from the font's mappings (none + /// from the file's own ActualText). + pub fn mapped(&self) -> bool { + self.upright.is_none() && !self.column && !self.authored.contains(&true) + } + /// The paint the last glyph is filled with. pub fn last_fill(&self) -> Option<&Paint> { self.runs.last()?.0.fill.as_ref() diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 234f4b8a8d..8caac7b0de 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -80,8 +80,8 @@ pub use file_info::{FileInfo, file_info}; #[cfg(any(test, feature = "read-count"))] pub use import::imports_of; pub use import::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(any(test, feature = "read-count"))] pub use pages::reads_of; @@ -213,7 +213,10 @@ pub struct ImportReport { #[derive(Clone, Debug)] pub struct ExportReport { pub bytes: Vec, + /// What the file leaves out of the document or writes differently. pub warnings: Vec, + /// Notes on the options: one the export didn't apply or applied another way. + pub setting_notes: Vec, } #[derive(Debug, thiserror::Error, PartialEq)] diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 55ab42ced3..77bc7d1d66 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -349,7 +349,7 @@ impl ColorOut { } /// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification - /// ([`crate::pdfx`]) → (the file, warnings). + /// ([`crate::pdfx`]) → (the file, notes on the output intent asked for). pub(crate) fn write_catalog(&self, pdf: Vec, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { let cat = self.catalog.as_ref(); if cat.is_none() && !display_doc_title { diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index e735d079fe..c6fdd8048d 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -204,13 +204,13 @@ fn linearised(pdf: &[u8], set: &PdfSettings) -> Result, PdfError> { } /// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing -/// data, to seal once the pages are final. What can't be done comes back in `warnings`. -pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: &mut Vec) -> Result, PdfError> { +/// data, to seal once the pages are final. The options it can't apply come back in `settings`. +pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, settings: &mut Vec) -> Result, PdfError> { let set = &opts.settings; let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) { (true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?, (true, true) => { - warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); + settings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); pdf } (false, _) => pdf, @@ -229,7 +229,7 @@ pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: & match linearised(&pdf, set) { Ok(out) => Ok(out), Err(e) => { - warnings.push(format!("the file isn't optimized for fast web view: {e}")); + settings.push(format!("the file isn't optimized for fast web view: {e}")); crate::encrypt::protect(pdf, set) } } diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index b11a764be2..0bad9d9f60 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -470,10 +470,10 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings), - None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings), + Some(w) => assert!(r.setting_notes.iter().any(|x| x.contains(w)), "{v}: {:?}", r.setting_notes), + None => assert!(r.warnings.is_empty() && r.setting_notes.is_empty(), "{v}: {:?} {:?}", r.warnings, r.setting_notes), } } // A knockout page group draws objects, not layers. d.page_knockout = true; let r = export(&d, json!({"createLayers": true})); oc_properties(&r.bytes, None, |props, _| assert!(props.is_none())); - assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("knockout page group")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 603138bd49..655ed43ea4 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -251,8 +251,9 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { assert!(r.bytes.starts_with(format!("%PDF-{header}").as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938], "{}", c.label()); assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{}", c.label()); - let raised = r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); - assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.warnings); + let raised = + r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); + assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.setting_notes); } } } @@ -288,11 +289,11 @@ fn pdfa2b_at_pdf_1_4_writes_a_long_page_as_pdf_1_6_within_14400_units() { assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{side} {in_points}: {media:?}"); assert!(((media[2] - media[0]) * unit[0] - side).abs() < side * 0.001, "{side} {in_points}: {media:?} {unit:?}"); let raised = "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6"; - assert!(r.warnings.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.setting_notes); } let r = report(&sized(14_400.0, 300.0), set.clone()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.4"), "{in_points}"); - assert!(!r.warnings.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.setting_notes); } } @@ -500,7 +501,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ 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()); assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label()); - assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.setting_notes); assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings); if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) { assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label()); diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index f7732821c1..0ab4d58c18 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -463,7 +463,7 @@ fn pdf_a_keeps_its_own_output_intent() { let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}})); let text = String::from_utf8_lossy(&r.bytes); assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped")); - assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF/A")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/pdf13.rs b/crates/pdf/src/tests/pdf13.rs index 4df56f3ff0..af7bbefe5d 100644 --- a/crates/pdf/src/tests/pdf13.rs +++ b/crates/pdf/src/tests/pdf13.rs @@ -69,7 +69,7 @@ fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() { let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}})); let r = export_with_report(&doc(1.0), &o).unwrap(); assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P ")); - assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.setting_notes); assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword))); for password in ["open", "owner"] { let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap(); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 2f59563e70..897e7241b4 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -349,7 +349,7 @@ fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { w.page(&mut ex, &sheet).unwrap(); assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet"); w.absorb(ex); - let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); + let (bytes, ..) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); let p = pages(&bytes); let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count(); assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1); diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index b3e105e4f6..3db63a4b2a 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -689,7 +689,7 @@ fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() { let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]); let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap(); let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("subset")), "{:?}", r.setting_notes); assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]); } diff --git a/crates/pdf/src/tests/reshaped.rs b/crates/pdf/src/tests/reshaped.rs new file mode 100644 index 0000000000..060eca40b7 --- /dev/null +++ b/crates/pdf/src/tests/reshaped.rs @@ -0,0 +1,119 @@ +//! #5235: type in a script whose letters reorder as they are set (here Devanagari) opens as type +//! only when that type draws what the file drew; otherwise the file's glyphs stay, as outlines. +//! The file is written from scratch from the PDF specification (ISO 32000-1 §9.7, composite +//! fonts), its font program a test font built in code over Source Sans 3 +//! ([`vectorcraft_text::test_fonts::matra_font`]): Ka draws Source Sans 3's `a` and the vowel sign +//! I, set before its consonant, draws its `i`. + +use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; +use vectorcraft_text::FontDb; +use vectorcraft_text::test_fonts::{MATRA_FAMILY, matra_font}; + +use crate::tests_import_fidelity::stream; +use crate::*; + +const KA: char = 'क'; +const SIGN_I: char = '\u{093F}'; + +/// The test font's glyph of `c` (Source Sans 3's glyph of the Latin letter that draws it) and its +/// advance in thousandths of an em. +fn glyph(c: char) -> (u32, f64) { + let face = FontDb::global().face(MATRA_FAMILY, "Regular").expect("the test font is added"); + let g = face.glyph_for(c); + (g, (face.advance(g) * 1000.0 / face.units_per_em()).round()) +} + +/// A 300 × 200 pt page showing the glyphs of `drawn`, in that order, in the test font at 40 pt +/// from (20, 100): a Type 0 font (Identity-H, two-byte glyph ids) with the font program embedded +/// and a ToUnicode map naming each glyph's character. `sign_width`: the advance the file gives +/// the vowel sign, in thousandths of an em (`None`: the font's). +fn page(drawn: &[char], sign_width: Option) -> Vec { + FontDb::global().add_font(matra_font().expect("the test font builds")); + let program = matra_font().unwrap(); + let hex: String = program.iter().map(|b| format!("{b:02X}")).collect::() + ">"; + let n = first_extra(1); + let rows: Vec = [KA, SIGN_I].iter().map(|&c| format!("<{:04X}> <{:04X}>", glyph(c).0, c as u32)).collect(); + let width = |c| sign_width.filter(|_| c == SIGN_I).unwrap_or(glyph(c).1); + let widths: Vec = [KA, SIGN_I].iter().map(|&c| format!("{} [{}]", glyph(c).0, width(c))).collect(); + let objs = [ + stream(&format!("/Length1 {} /Filter /ASCIIHexDecode", program.len()), &hex), + format!( + "<< /Type /FontDescriptor /FontName /MatratestSans-Regular /Flags 4 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1024 /Descent -400 /CapHeight 660 /StemV 80 /FontFile2 {n} 0 R >>" + ), + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MatratestSans-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + n + 1, + widths.join(" ") + ), + stream( + "", + &format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange {} beginbfchar\n{}\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + rows.len(), + rows.join("\n") + ), + ), + ]; + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let resources = format!( + "/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >>", + n + 2, + n + 3 + ); + let shown: String = drawn.iter().map(|&c| format!("{:04X}", glyph(c).0)).collect(); + let content = format!("BT /F1 40 Tf 20 100 Td <{shown}> Tj ET"); + pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None) +} + +fn open(bytes: &[u8]) -> ImportReport { + import_with_report(bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap() +} + +fn texts(d: &Document) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push(t.plain_text()); + } + }); + v +} + +fn outlines(d: &Document) -> usize { + let mut n = 0; + d.walk(|node| n += usize::from(is_glyph_outlines(node))); + n +} + +/// The file draws ककि as it is set: Ka, then the vowel sign before the Ka it follows in reading +/// order. Read glyph by glyph that is Ka, the sign, Ka, which reads किक: set as type, the sign +/// would move in front of the first Ka. The file's three glyphs stay as they are drawn. +#[test] +fn a_syllable_drawn_out_of_reading_order_keeps_the_files_glyphs() { + let report = open(&page(&[KA, SIGN_I, KA], None)); + assert_eq!(texts(&report.document), [""; 0], "no type that draws something else"); + assert_eq!(outlines(&report.document), 1, "the glyphs, as one path"); + assert!(report.warnings.iter().any(|w| w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); + assert!(report.warnings.iter().all(|w| !is_outlines_note(w) || w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); +} + +/// Letters the file draws in reading order make type that draws the same: they stay type. +#[test] +fn letters_drawn_in_reading_order_stay_type() { + let report = open(&page(&[KA, KA], None)); + assert_eq!(texts(&report.document), [format!("{KA}{KA}")]); + assert_eq!(outlines(&report.document), 0); + assert!(!report.warnings.iter().any(|w| is_outlines_note(w)), "{:?}", report.warnings); +} + +/// A mark the file gives no advance (as fonts do for the signs they set over a letter) belongs to +/// the line of its letter in a font written horizontally: it is not a glyph of vertical type, set +/// as an object of its own. Here the line it stays on then reads कि, whose sign type would set +/// before the Ka, so the line keeps the file's glyphs, the mark with them. +#[test] +fn a_mark_without_an_advance_stays_with_its_line() { + let report = open(&page(&[KA, SIGN_I], Some(0.0))); + assert_eq!(texts(&report.document), [""; 0], "the mark is no type of its own"); + assert_eq!(outlines(&report.document), 1, "both glyphs, as one path"); +} diff --git a/crates/pdf/src/tests/settings.rs b/crates/pdf/src/tests/settings.rs index 1750beebd2..e159b7b162 100644 --- a/crates/pdf/src/tests/settings.rs +++ b/crates/pdf/src/tests/settings.rs @@ -73,7 +73,7 @@ fn checks_refuse_what_the_writer_cant_honour() { } #[test] -fn options_not_applied_yet_come_back_as_warnings() { +fn options_not_applied_yet_come_back_as_setting_notes() { assert!(PdfSettings::default().warnings().is_empty()); for (v, word) in [ (json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"), @@ -110,7 +110,8 @@ fn options_not_applied_yet_come_back_as_warnings() { let r = export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() }) .unwrap(); - // The writer embeds the thumbnails it is given: without them, the one warning says so. - assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings); + // The writer embeds the thumbnails it is given: without them, the one note on the options says so. + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("thumbnails need the pages drawn"), "{:?}", r.setting_notes); assert!(r.bytes.starts_with(b"%PDF-1.7")); } diff --git a/crates/pdf/src/tests/spread.rs b/crates/pdf/src/tests/spread.rs new file mode 100644 index 0000000000..ebfe655f9a --- /dev/null +++ b/crates/pdf/src/tests/spread.rs @@ -0,0 +1,260 @@ +//! Gradients that repeat or reflect. PDF's axial and radial shadings pad past their ends, so a +//! linear, radial or diamond gradient is written with its stops repeated as many times as the +//! shape needs (axial and radial shadings, which PDF/A allows); a conic's sweep shading runs over +//! one run and repeats or reflects it. Read back, each draws as the canvas draws the gradient. + +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_testkit::raster::render_artboard; + +use crate::*; + +/// A page-sized square filled (or stroked 16 pt wide, `stroke`) with a red-to-blue gradient of +/// `kind` placed on `geom`, spreading by `spread` 3 times. +fn doc(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, stroke: bool) -> Document { + doc_n(kind, spread, geom, stroke, 3) +} + +/// [`doc`], spreading `repeats` times. +fn doc_n(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, stroke: bool, repeats: u32) -> Document { + 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 mut gp = GradientPaint::new(Gradient { spread, repeats, ..Gradient::new(kind, stops) }); + gp.geom = Some(geom); + let paint = Paint::Gradient(Box::new(gp)); + let ap = if stroke { Appearance::basic(Paint::None, paint, 16.0) } else { Appearance::basic(paint, Paint::None, 0.0) }; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + d +} + +fn cases() -> Vec<(&'static str, GradientKind, GradientGeom)> { + let c = Point::new(100.0, 100.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(60.0, -25.0), aspect: 0.7, focal: None }; + vec![ + ("linear", GradientKind::Linear, GradientGeom { start: Point::new(70.0, 80.0), end: Point::new(150.0, 110.0), aspect: 1.0, focal: None }), + ("radial", GradientKind::Radial, geom), + ("off-center radial", GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-15.0, 12.0)), ..geom }), + ("conic", GradientKind::Conic, geom), + ("diamond", GradientKind::Diamond, geom), + ] +} + +/// Does the PDF text hold an image XObject? +fn image(text: &str) -> bool { + text.contains("/Subtype/Image") || text.contains("/Subtype /Image") +} + +fn uncompressed(d: &Document, o: &PdfOptions) -> String { + String::from_utf8_lossy(&export_with_report(d, o).unwrap().bytes).into_owned() +} + +/// Every kind, filled and stroked, repeating and reflecting, written and read back, looks as the +/// canvas paints it, inside the vector and past its ends. +#[test] +fn spread_gradients_come_back_as_the_canvas_draws_them() { + let _held = super::import_fidelity::SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for (what, kind, geom) in cases() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for stroke in [false, true] { + let d = doc(kind, spread, geom, stroke); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + // Hard edges (where Repeat starts the stops again) differ by a pixel's + // antialiasing at most. + let (a, b) = (render_artboard(&back), render_artboard(&d)); + let st = vectorcraft_testkit::raster::perceptual_diff(&a, &b, 10.0); + assert!( + (st.bad_pixels as f64) < 0.03 * st.pixels as f64, + "{what} {spread:?} stroke {stroke}: {} of {} pixels differ", + st.bad_pixels, + st.pixels + ); + } + } + } +} + +/// A linear, radial or diamond gradient is written as axial or radial shadings (in PDF/A too); a +/// conic as its sweep shading. +#[test] +fn the_shadings_written() { + let mut pdfa = PdfOptions::uncompressed(); + (pdfa.settings.standard, pdfa.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version()); + for (what, kind, geom) in cases() { + for o in [PdfOptions::uncompressed(), pdfa.clone()] { + let text = uncompressed(&doc(kind, GradientSpread::Reflect, geom, false), &o); + let want = match kind { + GradientKind::Linear | GradientKind::Diamond => "/ShadingType 2", + GradientKind::Radial => "/ShadingType 3", + _ => "/ShadingType 1", + }; + if kind == GradientKind::Conic && o.settings.standard != Standard::None { + // PDF/A has no calculator functions: the sweep is an image. + continue; + } + assert!(text.contains(want), "{what}: {want}"); + assert!(!image(&text), "{what}: no image"); + } + } +} + +/// An axial shading with a sharp step between two colors reads back with the step where it was, +/// not blurred across the samples around it. +#[test] +fn a_sharp_step_in_a_shading_reads_back_sharp() { + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let stops = vec![GradientStop::new(0.0, red), GradientStop::new(0.37, red), GradientStop::new(0.37, blue), GradientStop::new(1.0, blue)]; + let mut gp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + gp.geom = Some(GradientGeom { start: Point::new(0.0, 100.0), end: Point::new(200.0, 100.0), aspect: 1.0, focal: None }); + let mut d = Document::new(200.0, 200.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), ap)).unwrap(); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = None; + back.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let g = found.expect("a gradient"); + let geom = g.geom.unwrap(); + for (x, want) in [(73.0, red), (72.0, red), (75.0, blue), (76.0, blue)] { + let t = geom.param_at(GradientKind::Linear, Point::new(x, 100.0)); + let got = g.gradient.sample(t as f32).0.to_rgb(); + assert!(got.iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 0.02), "x {x}: {got:?}"); + } +} + +/// A gradient written with 256 runs of its stops (as many as PDF export writes), so that every +/// sample the reader takes across the shading lands at the start of a run, reads back with its +/// runs rather than as one color. +#[test] +fn many_runs_read_back_as_runs() { + let _held = super::import_fidelity::SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // A run of 0.4 pt from the shape's left edge, across 160 pt: capped at 256 runs, 102.4 pt. + let geom = GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(60.0, 100.0), aspect: 1.0, focal: None }; + let d = doc_n(GradientKind::Linear, GradientSpread::Repeat, geom, false, 100); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = None; + back.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let g = found.expect("a gradient"); + let geom = g.geom.unwrap(); + for run in [0.0, 1.0, 50.0, 200.0, 255.0] { + for (u, want) in [(0.25, [0.75, 0.0, 0.25]), (0.75, [0.25, 0.0, 0.75])] { + let x = 20.0 + 0.4 * (run + u); + let t = geom.param_at(GradientKind::Linear, Point::new(x, 100.0)); + let got = g.gradient.sample(t as f32).0.to_rgb(); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.05), "run {run} at {u}: {got:?}"); + } + } +} + +/// A diamond centered in the shape is written in PDF as four linear gradients, each with the runs +/// from the center out to the shape's corner, not padded short of it. +#[test] +fn a_diamond_writes_each_quadrant_from_its_center_out() { + // From the center to a corner: 16 lengths of the vector, 10 runs each. + let c = Point::new(100.0, 100.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(10.0, 0.0), aspect: 1.0, focal: None }; + let d = doc_n(GradientKind::Diamond, GradientSpread::Repeat, geom, false, 10); + let report = export_with_report(&d, &PdfOptions::uncompressed()).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("at most 256 runs")), "{:?}", report.warnings); +} + +/// A repeating gradient whose run reaches past the coordinates PDF holds is drawn as an image +/// rather than left out, for a linear, a radial and a diamond gradient. +#[test] +fn a_run_too_long_for_pdf_is_an_image() { + let c = Point::new(100.0, 100.0); + for (what, kind) in [("linear", GradientKind::Linear), ("radial", GradientKind::Radial), ("diamond", GradientKind::Diamond)] { + // Three runs of the vector, each longer than PDF's coordinates reach. + let geom = GradientGeom { start: c, end: c + Vec2::new(1.2e10, 0.0), aspect: 1.0, focal: None }; + let text = uncompressed(&doc(kind, GradientSpread::Repeat, geom, false), &PdfOptions::uncompressed()); + assert!(image(&text), "{what}: drawn as an image"); + } +} + +/// A Perceptual gradient of a spot ink stays Separation shadings in that ink while its repeated +/// stops' ink curve fits, and is an image (rather than tints mixed the Classic way) once it no +/// longer does, for a linear and a diamond gradient. +#[test] +fn a_perceptual_spot_gradient_too_long_once_repeated_is_an_image() { + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + // Gold and paper white in turn: a curve of many tints in each run. + let stops: Vec = (0..=4) + .map(|i| match i % 2 { + 0 => { + let mut s = GradientStop::new(i as f32 / 4.0, gold); + (s.swatch, s.tint) = (Some("Gold".into()), 1.0); + s + } + _ => GradientStop::new(i as f32 / 4.0, Color::cmyk(0.0, 0.0, 0.0, 0.0)), + }) + .collect(); + let c = Point::new(100.0, 100.0); + for (kind, short, long) in [ + ( + GradientKind::Linear, + GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(20.4, 100.0), aspect: 1.0, focal: None }, + GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(180.0, 100.0), aspect: 1.0, focal: None }, + ), + ( + GradientKind::Diamond, + GradientGeom { start: c, end: c + Vec2::new(0.3, 0.0), aspect: 1.0, focal: None }, + GradientGeom { start: c, end: c + Vec2::new(80.0, 0.0), aspect: 1.0, focal: None }, + ), + ] { + for (geom, repeats, image_wanted) in [(long, 2, false), (short, 1, true)] { + let g = Gradient { + method: vectorcraft_color::GradientMethod::Perceptual, + spread: GradientSpread::Repeat, + repeats, + ..Gradient::new(kind, stops.clone()) + }; + let padded = g.padded_over(&geom, Rect::new(20.0, 20.0, 180.0, 180.0)).unwrap(); + assert!(g.fits_ink_tints() && padded.gradient.fits_ink_tints() != image_wanted, "{kind:?} {repeats}"); + let mut gp = GradientPaint::new(g); + gp.geom = Some(geom); + let mut d = Document::new(200.0, 200.0); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + d.swatches.push(vectorcraft_color::Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + let text = uncompressed(&d, &PdfOptions::uncompressed()); + assert_eq!(image(&text), image_wanted, "{kind:?} {repeats}"); + assert_eq!(text.contains("[/Separation/Gold"), !image_wanted, "{kind:?} {repeats}"); + } + } +} + +/// A gradient that repeats with no stops (a file can hold one) exports, filled and stroked. +#[test] +fn a_spread_gradient_without_stops_exports() { + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(kind, vec![]) }); + let paint = Paint::Gradient(Box::new(g)); + let mut d = Document::new(200.0, 200.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(paint.clone(), paint, 4.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + export(&d, &PdfOptions::default()).unwrap(); + } +} + +/// A run far too short for the shape is written with at most 256 runs, padding past them, with a +/// warning. +#[test] +fn far_repeats_are_capped_with_a_warning() { + let geom = GradientGeom { start: Point::new(20.0, 20.0), end: Point::new(20.5, 20.0), aspect: 1.0, focal: None }; + let report = export_with_report(&doc(GradientKind::Linear, GradientSpread::Repeat, geom, false), &PdfOptions::uncompressed()).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("at most 256 runs")), "{:?}", report.warnings); +} diff --git a/crates/pdf/src/tests/stroke.rs b/crates/pdf/src/tests/stroke.rs index 91436a093c..d57cbc42d0 100644 --- a/crates/pdf/src/tests/stroke.rs +++ b/crates/pdf/src/tests/stroke.rs @@ -52,7 +52,7 @@ pub(crate) fn painted_boxes(d: &Document) -> (Vec, Vec) { fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let (d, n) = line_doc(arrow_stroke(kind, align, 1.0)); + let (d, n) = line_doc(arrow_stroke(kind.clone(), align, 1.0)); let bp = n.path_data().unwrap().to_bezpath(); let st = n.appearance.stroke().unwrap(); let pieces = stroke_pieces(&bp, st); diff --git a/crates/pdf/src/tests/webview.rs b/crates/pdf/src/tests/webview.rs index a40db425e7..6fc50d0d54 100644 --- a/crates/pdf/src/tests/webview.rs +++ b/crates/pdf/src/tests/webview.rs @@ -345,7 +345,7 @@ fn thumbnails_are_embedded_on_every_page() { assert_eq!(import(&r.bytes).unwrap().artboards.len(), 3); // Asked for without the pages drawn: none, with a warning. Thumbnails too large are refused. let r = export_with_report(&d, &options(json!({"thumbnails": true}))).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); + assert!(r.setting_notes.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); o.thumbnails[1] = thumb(107, 20, 0); assert!(matches!(export(&d, &o), Err(PdfError::BadSetting(_)))); } diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 0e149579f5..26ba5b668b 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -616,6 +616,7 @@ fn decode_paint(v: &Value, at: &str) -> Result { } stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); g.angle = g.angle.rem_euclid(360.0); + g.gradient.repeats = g.gradient.repeat_count(); let conic = g.gradient.kind == vectorcraft_color::GradientKind::Conic; let no_focal = conic || g.gradient.kind == vectorcraft_color::GradientKind::Diamond; if let Some(geom) = &mut g.geom { @@ -1243,8 +1244,11 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, match o.id { Some(id) => { let mut n = originals.remove(&id).ok_or_else(|| { - let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; - out_err(&at, format!("{} {why}", id.0)) + if wanted.contains(&id) { + out_err(&at, format!("{} is listed twice", id.0)) + } else { + out_err(&at, format!("{} is not the id of an input object", id.0)) + } })?; // Type, images and symbol instances are only read: writing one would make it a path. if input_kind(&n).is_some() && !o.kept { diff --git a/crates/render/src/dither.rs b/crates/render/src/dither.rs index dfd2f53927..0bcc48a17f 100644 --- a/crates/render/src/dither.rs +++ b/crates/render/src/dither.rs @@ -58,7 +58,7 @@ fn cells(px: f64) -> u16 { fn key(g: &GradientPaint, place: Rect, b: Rect, cols: u16, rows: u16, win: &[Range; 2], ink: Ink, fill: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); let gr = &g.gradient; - (gr.kind as u8, gr.dither && fill, gr.method as u8).hash(&mut h); + (gr.kind as u8, gr.dither && fill, gr.method as u8, gr.painted_spread()).hash(&mut h); for s in &gr.stops { (s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h); s.color.key().hash(&mut h); diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index c03a6cc0f3..96bddc17f1 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -307,6 +307,36 @@ fn drop_shadow_darkens_outside_the_shape() { assert_eq!(lum(img.pixel(15, 15)), WHITE); } +/// A Transform effect listed after a Drop Shadow transforms the shadow with the art: halving a +/// 40 pt square about its center halves the shadow's 10 pt offset, with Scale Strokes & Effects +/// off or on, and for a group as for a path. +#[test] +fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() { + let r = Rect::new(20.0, 20.0, 60.0, 60.0); + let shadow = ("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"})); + for strokes in [false, true] { + let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes})); + let path = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![shadow.clone(), transform.clone()]); + let mut group = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]); + let l = group.layers[0].id; + let square = group.layers[0].children().and_then(|c| c.first()).cloned().unwrap(); + group.remove(square.id).unwrap(); + let mut g = Node::group(group.alloc_id(), vec![square]); + g.appearance.effects = [shadow.clone(), transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + group.insert(Some(l), 0, g).unwrap(); + for (what, d) in [("path", path), ("group", group)] { + let img = render(&d); + // The square now covers 30..50 and its shadow 35..55. + assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}"); + assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37)); + assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55"); + // The area redrawn for it takes in the 5 pt shadow around the 30..50 square. + let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap(); + assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}"); + } + } +} + /// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and /// right; `shade` "color" or "darkness". fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index dd83e4cee7..1d39ece10c 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -19,7 +19,15 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds, f.ink), Paint::Gradient(g) => { - let geom = g.resolve(bounds); + // A gradient that repeats or reflects: one run of its stops over its period, which + // the renderer extends past both ends. + let spread = g.gradient.is_spread(); + let geom = if spread { g.resolve(bounds).period(g.gradient.kind, g.gradient.repeat_count()) } else { g.resolve(bounds) }; + let extend = match g.gradient.spread { + _ if !spread => peniko::Extend::Pad, + vectorcraft_color::GradientSpread::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Repeat, + }; let adjusted = f.ink.gradient(&g.gradient); let gradient = adjusted.as_ref().unwrap_or(&g.gradient); let ink = if adjusted.is_some() { f.ink.base() } else { f.ink }; @@ -42,9 +50,11 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } GradientKind::Conic => { // One turn about the origin, carried onto the extent ellipse: its frame starts - // the sweep along the vector, and a mirrored frame reverses it. + // the sweep along the vector, and a mirrored frame reverses it. Repeated `n` + // times, one run takes 1/n of the turn. ctx.set_paint_transform(geom.conic_frame()); - peniko::Gradient::new_sweep((0.0, 0.0), 0.0, std::f32::consts::TAU).with_stops(stops.as_slice()) + let turn = if spread { std::f32::consts::TAU / g.gradient.repeat_count() as f32 } else { std::f32::consts::TAU }; + peniko::Gradient::new_sweep((0.0, 0.0), 0.0, turn).with_stops(stops.as_slice()) } // peniko has no diamond gradient: it is drawn as pixels, as a dithered fill is. GradientKind::Diamond => return crate::dither::set_dithered_paint(ctx, g, bounds, bounds, f.ink, false), @@ -57,7 +67,7 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr }; // Colour and opacity blend each on its own, as the model samples them and as the PDF // and SVG exports draw them (peniko's default premultiplies). - ctx.set_paint(grad.with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); + ctx.set_paint(grad.with_extend(extend).with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); true } Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f), diff --git a/crates/render/src/pattern/tests.rs b/crates/render/src/pattern/tests.rs index 76aad7fe6f..701afe9b41 100644 --- a/crates/render/src/pattern/tests.rs +++ b/crates/render/src/pattern/tests.rs @@ -251,19 +251,30 @@ fn kept_tiles_stay_within_the_budget() { #[test] fn kept_tiles_count_the_images_they_could_not_decode() { use vectorcraft_doc::{ImageBlob, ImageObject}; - let mut d = doc_with_pattern(TileType::Grid); - // A PNG signature with nothing after it. - d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; - let mut r = Renderer::new(); - let start = tiles_rasterized(); - for n in 0..3 { - let before = crate::paint::decode_failures(); - frame(&mut r, &d, 1.0); - assert!(crate::paint::decode_failures() > before, "frame {n}"); + // A tile of art with an image is drawn again when the fonts change, and other tests add + // fonts, so the frames come from a run in which the fonts stayed the same. Each run draws a + // new document, whose first frame finds no tile kept from an earlier run. + let _fonts = crate::tests_fontchange::fonts_steady(); + let (failures, rasterized) = crate::tests_fontchange::with_fonts_unchanged(|| { + let mut d = doc_with_pattern(TileType::Grid); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = + ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; + let mut r = Renderer::new(); + let start = tiles_rasterized(); + let failures = [0, 1, 2].map(|_| { + let before = crate::paint::decode_failures(); + frame(&mut r, &d, 1.0); + crate::paint::decode_failures() > before + }); + (failures, tiles_rasterized() - start) + }); + for (n, failed) in failures.into_iter().enumerate() { + assert!(failed, "frame {n}"); } - assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept"); + assert_eq!(rasterized, 1, "the later frames draw from the tile kept"); } #[test] diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 2bf0d165db..1d61a2656c 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -212,11 +212,12 @@ impl Renderer { let tol = 2f64.powi(level); // Reach a pixel past the stroke, so its antialiased edge has colour under it. let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); + let unrolled = g.unrolled(); Arc::new( slices .into_iter() .map(|s| { - let (b, paint) = (s.shape.bounding_box(), s.paint(g)); + let (b, paint) = (s.shape.bounding_box(), s.paint(&unrolled)); (s.shape, paint, b) }) .collect(), diff --git a/crates/render/src/tests/strokegradient.rs b/crates/render/src/tests/strokegradient.rs index 961f18590f..f286a04252 100644 --- a/crates/render/src/tests/strokegradient.rs +++ b/crates/render/src/tests/strokegradient.rs @@ -47,6 +47,18 @@ fn along_a_quarter_of_the_length_is_a_quarter_of_the_gradient_in_any_direction() } } +/// A gradient that reflects twice runs its stops twice along the stroke, the second run +/// backwards. +#[test] +fn along_a_reflecting_gradient_runs_its_stops_that_many_times() { + let g = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..Gradient::default() }; + let img = render(&line((20.0, 100.0), (180.0, 100.0)), g, StrokeGradientMode::Along, true); + for (f, want) in [(0.125, 0.75), (0.375, 0.25), (0.625, 0.25), (0.875, 0.75)] { + let x = 20.0 + 160.0 * f; + assert!((grey(&img, x, 100.0) - want).abs() < 0.03, "at {f}: {} vs {want}", grey(&img, x, 100.0)); + } +} + #[test] fn across_runs_from_the_left_edge_to_the_right() { // Travelling right, the left edge is the top one (y 90), the right edge the bottom (y 110). diff --git a/crates/render/tests/it/golden.rs b/crates/render/tests/it/golden.rs index 4820cf8b60..d6962e0e67 100644 --- a/crates/render/tests/it/golden.rs +++ b/crates/render/tests/it/golden.rs @@ -300,7 +300,7 @@ fn arrowheads_add_ink_at_the_ends() { // Every arrowhead kind renders. for a in Arrowhead::ALL { let mut b = DocBuilder::new(100.0, 100.0); - b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {}); + b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a.clone())), |_| {}); assert!(render(&b.build()).ink() > plain.ink(), "{a:?}"); } } diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 9a9e059fba..66d61e9bbb 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -9,4 +9,5 @@ mod golden; mod gradient_alpha; mod gradient_text; mod radial_focal; +mod spread; mod stroke_geometry; diff --git a/crates/render/tests/it/spread.rs b/crates/render/tests/it/spread.rs new file mode 100644 index 0000000000..9e0c73d545 --- /dev/null +++ b/crates/render/tests/it/spread.rs @@ -0,0 +1,93 @@ +//! Gradients that repeat or reflect: the canvas draws each kind's stops `repeats` times along its +//! vector and on past its ends, as the model's parameter gives them, on fills and strokes. + +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, Paint}; +use vectorcraft_doc::{Appearance, Document}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_testkit::raster::{Image, render_view}; + +/// A white-to-black gradient of `kind` on `geom` that spreads by `spread`, twice. +fn gradient(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, dither: bool) -> GradientPaint { + let mut gp = GradientPaint::new(Gradient { kind, spread, repeats: 2, dither, ..Gradient::default() }); + gp.geom = Some(geom); + gp +} + +/// A 100 pt square filled with `fill` and stroked `width` wide with `stroke`. +fn square(fill: Paint, stroke: Paint, width: f64) -> Document { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(fill, stroke, width), |_| {}); + b.build() +} + +/// Every 5th pixel inside `inside`, away from where Repeat jumps from black back to white and +/// from a conic's center, has the gray the model gives it (pixel centers), within what a pixel's +/// step on the ramp and dither's noise allow; at least `least` pixels are checked. +fn assert_follows_model(img: &Image, g: &GradientPaint, inside: Rect, least: usize, what: &str) { + let geom = g.geom.unwrap(); + let kind = g.gradient.kind; + let mut checked = 0; + for y in (2..100).step_by(5) { + for x in (2..100).step_by(5) { + let p = Point::new(x as f64 + 0.5, y as f64 + 0.5); + if !inside.contains(p) || (kind == GradientKind::Conic && p.distance(geom.start) < 12.0) { + continue; + } + let at = |q: Point| g.gradient.spread_param(geom.param_at(kind, q)); + let t = at(p); + if g.gradient.spread == GradientSpread::Repeat && !(0.06..0.94).contains(&t) { + continue; + } + // How far the gray moves across a pixel there: pixels drawn from a grid of cells (a + // diamond's, a dithered fill's) are sampled up to half a pixel away. + let step = [Vec2::new(0.5, 0.0), Vec2::new(0.0, 0.5), Vec2::new(-0.5, 0.0), Vec2::new(0.0, -0.5)] + .map(|d| 2.0 * (at(p + d) - t).abs()) + .into_iter() + .fold(0.0, f64::max); + let tolerance = 6 + (step * 255.0).ceil() as u8; + let want = ((1.0 - t.clamp(0.0, 1.0)) * 255.0).round() as u8; + let got = img.over_white(x, y)[0]; + assert!(got.abs_diff(want) <= tolerance, "{what} ({x},{y}) t={t:.3}: got {got}, want {want} (within {tolerance})"); + checked += 1; + } + } + assert!(checked >= least, "{what}: only {checked} pixels checked"); +} + +fn geoms() -> [(GradientKind, GradientGeom); 5] { + let c = Point::new(50.0, 50.0); + let level = GradientGeom { start: c, end: c + Vec2::new(36.0, 12.0), aspect: 0.7, focal: None }; + [ + (GradientKind::Linear, GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(70.0, 45.0), aspect: 1.0, focal: None }), + (GradientKind::Radial, level), + (GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-10.0, 6.0)), ..level }), + (GradientKind::Conic, level), + (GradientKind::Diamond, level), + ] +} + +/// Linear, radial (centered and off-center), conic and diamond fills repeat and reflect as the +/// model says, inside the vector and past its ends; so does a dithered fill, drawn as pixels. +#[test] +fn fills_repeat_and_reflect_for_every_kind() { + for (kind, geom) in geoms() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for dither in [false, true] { + let g = gradient(kind, spread, geom, dither); + let img = render_view(&square(Paint::Gradient(Box::new(g.clone())), Paint::None, 0.0), 100, 100); + assert_follows_model(&img, &g, Rect::new(12.0, 12.0, 88.0, 88.0), 100, &format!("{kind:?} {spread:?} dither {dither}")); + } + } + } +} + +/// A stroke's gradient repeats out to its miters too. +#[test] +fn a_stroke_repeats_out_past_the_paint_box() { + for (kind, geom) in geoms() { + let g = gradient(kind, GradientSpread::Reflect, geom, false); + let img = render_view(&square(Paint::None, Paint::Gradient(Box::new(g.clone())), 12.0), 100, 100); + assert_follows_model(&img, &g, Rect::new(5.0, 5.0, 15.0, 95.0), 20, &format!("{kind:?} stroke")); + } +} diff --git a/crates/render/tests/it/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs index a8813da23a..2580fc79c8 100644 --- a/crates/render/tests/it/stroke_geometry.rs +++ b/crates/render/tests/it/stroke_geometry.rs @@ -114,7 +114,7 @@ fn a_hollow_circle_head_is_empty_inside() { fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round); + let d = arrow_doc(kind.clone(), align, 1.0, |s| s.cap = LineCap::Round); let h = head_of(&d); if align == ArrowAlign::Tip { assert_eq!(h.tip, Point::new(80.0, 50.0)); diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index ada9a4375c..f7e067aa30 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; -use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, GradientGeom, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind}; use vectorcraft_effects::RasterFx; use vectorcraft_geom::shapes::{self, CornerKind}; @@ -177,11 +177,18 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .iter() .map(|t| match t.strip_prefix('-') { Some(off) => (off, 0), - None => (t.as_str(), 1), + // `tag=N` picks the font's alternate N: CSS takes the number as the feature's value. + None => { + let on = t.strip_prefix('+').unwrap_or(t); + match on.split_once('=').map(|(tag, n)| (tag, n.parse::())) { + Some((tag, Ok(n))) => (tag, n), + _ => (on, 1), + } + } }) // A feature tag is four letters or digits: anything else could end the CSS string. .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) - .map(|(tag, on)| format!("\"{tag}\" {on}")) + .map(|(tag, value)| format!("\"{tag}\" {value}")) .collect(); // Proportional Metrics: `palt` (SVG writes vertical type as outlines). if st.proportional_metrics { @@ -199,6 +206,14 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { p } +/// The decimals, at least `least` and at most 12, for numbers that place a period of length +/// `period` repeated out to `reach` (in the same units): each period then rounds by at most +/// 10^-decimals, and over the `reach / period` periods that adds up to at most a hundredth of one. +fn period_decimals(period: f64, reach: f64, least: u8) -> u8 { + let d = (100.0 * reach / (period * period)).log10().ceil(); + if d.is_nan() || d == f64::INFINITY { 12 } else { d.clamp(f64::from(least), 12.0) as u8 } +} + /// A colour at `opacity`: `#rrggbb` when opaque, else `rgba()`. fn color_css(c: &Color, opacity: f32) -> String { if opacity >= 1.0 { @@ -240,13 +255,22 @@ impl CssUnits { /// `points` in these units (`0` without a unit). fn len(self, points: f64) -> String { - let per_point = match self { + self.len_to(points, 3) + } + + /// One point in these units. + fn per_point(self) -> f64 { + match self { CssUnits::Px | CssUnits::Pt => 1.0, CssUnits::Mm => 25.4 / 72.0, CssUnits::Cm => 2.54 / 72.0, CssUnits::In => 1.0 / 72.0, - }; - match fmt_num(points * per_point, 3) { + } + } + + /// [`Self::len`] with `decimals` decimals. + fn len_to(self, points: f64, decimals: u8) -> String { + match fmt_num(points * self.per_point(), decimals) { zero if zero == "0" => zero, v => format!("{v}{}", self.name()), } @@ -559,9 +583,37 @@ impl Rules<'_> { /// writes (midpoints as stops of their own). `None` for freeform gradients. fn gradient(&mut self, g: &GradientPaint, b: Rect, opacity: f32) -> Option { let geom = g.resolve(b); - let stops = |pos: &dyn Fn(f32) -> f64| { + // A linear, radial or diamond gradient that repeats or reflects: a repeating gradient of + // one cycle (a run, or a run and its mirror) over that cycle's placement + // (https://www.w3.org/TR/css-images-3/#repeating-gradients). A conic one: its stops + // repeated around the turn. + let kind = g.gradient.kind; + let unrolled = g.gradient.unrolled(); + let (gradient, geom, repeating) = match g.gradient.cycle() { + Some((cycle, runs)) if kind != GradientKind::Conic => { + // CSS radials start at their center: the cycle is scaled about it. + let centered = GradientGeom { focal: None, ..geom }; + let cycle_geom = centered.period(kind, g.gradient.repeat_count()).levels_scaled(kind, f64::from(runs)); + let geom = GradientGeom { focal: geom.focal, ..cycle_geom }; + (std::borrow::Cow::Owned(cycle), geom, "repeating-") + } + _ => (unrolled, geom, ""), + }; + // A repeating gradient's period is the span from its first stop to its last, repeated + // across the box (from 0% to `reach`%): its positions get at least four decimals, and more + // when the box holds so many periods that their rounding would add up to more than a + // hundredth of one ([`period_decimals`]). + let span = gradient.expanded().next().zip(gradient.expanded().last()).map(|(a, z)| (a.0, z.0)); + let decimals = |pos: &dyn Fn(f32) -> f64, reach: f64| match span { + Some((a, z)) if !repeating.is_empty() => { + let (p0, p1) = (pos(a) * 100.0, pos(z) * 100.0); + period_decimals((p1 - p0).abs(), p0.abs().max((reach - p0).abs()), 4) + } + _ => 2, + }; + let stops = |pos: &dyn Fn(f32) -> f64, decimals: u8| { let stops: Vec = - g.gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect(); + gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect(); stops.join(", ") }; match g.gradient.kind { @@ -578,7 +630,7 @@ impl Rules<'_> { let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs(); let from = (geom.start - b.center()).dot(dir); let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 }; - Some(format!("linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) + Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos, decimals(&pos, 100.0)))) } // CSS has no diamond gradient: the ellipse through the square's corners stands in. GradientKind::Radial | GradientKind::Diamond => { @@ -588,13 +640,30 @@ impl Rules<'_> { } else if geom.focal.is_some() { self.warn("radial gradients start at their centre (an off-centre focal point is left out)"); } + // The positions are a share of the radius, and the box reaches out to its farthest + // corner. A repeating gradient's period scales with its radius, so the radius gets + // the decimals that keep the periods in place out to that corner too. + let units = self.opts.units; + let small = r * geom.aspect.abs().min(1.0); + let far = [(b.x0, b.y0), (b.x1, b.y0), (b.x0, b.y1), (b.x1, b.y1)] + .into_iter() + .map(|(x, y)| (Point::new(x, y) - geom.start).hypot()) + .fold(0.0, f64::max); + let reach = if small > 1e-12 { 100.0 * far / small } else { 0.0 }; + let pos = |t: f32| t as f64; + let len = match span { + Some((a, z)) if !repeating.is_empty() => { + period_decimals(small * f64::from(z - a).abs() * units.per_point(), far * units.per_point(), 3) + } + _ => 3, + }; let shape = if (geom.aspect - 1.0).abs() < 1e-9 { - format!("circle {}", self.len(r)) + format!("circle {}", units.len_to(r, len)) } else { - format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect)) + format!("ellipse {} {}", units.len_to(r, len), units.len_to(r * geom.aspect, len)) }; let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0)); - Some(format!("radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) + Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&pos, decimals(&pos, reach)))) } GradientKind::Conic => { // CSS sweeps clockwise from the `from` angle, 0deg straight up @@ -607,7 +676,7 @@ impl Rules<'_> { if (geom.aspect.abs() - 1.0).abs() > 1e-9 && geom.unit_frame().is_some() { self.warn("conic gradients are written circular (an elliptical sweep is left out)"); } - let mut stops: Vec<(f32, Color, f32)> = g.gradient.expanded().map(|(t, c, o, _)| (t, c, o)).collect(); + let mut stops: Vec<(f32, Color, f32)> = gradient.expanded().map(|(t, c, o, _)| (t, c, o)).collect(); if geom.is_mirrored() { stops.reverse(); stops.iter_mut().for_each(|s| s.0 = 1.0 - s.0); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..05107bfb65 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -26,6 +26,7 @@ mod nodes; mod output; mod paint; mod text; +mod variables; use nodes::SymbolDef; pub(crate) use nodes::{MASK_FLAGS, Reuse}; @@ -102,6 +103,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: region_boxes: None, art_reach: HashMap::new(), warnings: vec![], + setting_notes: vec![], id_prefix, linked: Vec::new(), instance_xf: Affine::IDENTITY, @@ -117,8 +119,10 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: layer_nest: 0, non_latin1_names: false, foreign_ns: BTreeMap::new(), + bound: HashMap::new(), }; w.assign_name_ids(); + w.assign_bound_ids(); for layer in &doc.layers { w.count_colorized_images(layer); } @@ -135,9 +139,11 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: w.line(""); } + // Include Variable Data, once the body has written the bound objects' ids. + let variables = w.variable_sets(); // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { - w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); + w.note_setting("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); Encoding::Utf8 } else { opts.encoding @@ -207,6 +213,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!(""})), + ( + "command.batch", + json!({"label": "B", "commands": [{"command": "object.move", "params": {"dx": 1, "n": {"a": [1, 2.5, {"b": null}]}}}]}), + ), + (STOP_STEP, json!({"message": "Paint it", "allowContinue": false})), + ("object.group", json!({})), + ("object.group", json!([])), + ("object.group", Value::Null), + ]; + for (command, params) in steps { + for first in [true, false] { + let before = if first { vec![] } else { vec![("object.group".to_string(), json!({}))] }; + let mut set = ActionSet::new("Set", vec![Action::new("A", before), Action::new("B", vec![("object.group".into(), json!({}))])]); + let len = files::write_set(&set).len(); + set.actions[0].steps.push((command.to_string(), params.clone())); + let added = files::write_set(&set).len() - len; + let counted = files::step_bytes(command, ¶ms); + let first_bytes = if first { files::FIRST_STEP_BYTES } else { 0 }; + assert_eq!(counted + first_bytes, added, "{command} {params}, first: {first}"); + } + } + let mut set = ActionSet::new("Set", vec![Action::new("A", vec![("object.group".into(), json!({"n": "日本"})); 3])]); + let len = files::write_set(&set).len(); + set.actions[0].excluded_steps = BTreeSet::from([0, 2]); + assert_eq!(files::write_set(&set).len(), len + 2 * files::EXCLUDED_BYTES); + } + + /// The params of `object.move` by 1 as the journal keeps them, which a recording records. + fn recorded_move(app: &mut VectorcraftApp) -> Value { + app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap(); + let moved = app.session.journal.last().unwrap().1.clone(); + app.run("edit.undo", json!({})).unwrap(); + moved + } + + /// An ordinary command recorded as the 10,001st step of an action does not run: the document, + /// the journal and the undo history stay as they were, the recording goes on, and Stop + /// Playing/Recording leaves a set that Load Actions… reads. This holds for Begin Recording's new + /// action and for New Action's recording into an action with steps of its own. + #[test] + fn recorded_commands_keep_an_action_at_10_000_steps() { + for new_action in [false, true] { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let filler = ("object.group".to_string(), json!({})); + if new_action { + app.run(NEW_ACTION, json!({"set": 0, "name": "Long"})).unwrap(); + app.ui.action_sets[0].actions[1].steps = vec![filler; files::MAX_ACTION_STEPS - 1]; + } else { + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 1..files::MAX_ACTION_STEPS { + app.session.journal.push(filler.clone()); + } + } + app.run("object.move", moved.clone()).unwrap(); + assert_eq!(x(&app), 1.0, "the 10,000th step runs"); + let (journal, undo) = (app.session.journal.len(), undo_steps(&app)); + let steps = "the action already has 10,000 steps"; + assert_eq!(app.run("object.move", moved.clone()).unwrap_err(), steps, "new action: {new_action}"); + assert_eq!((x(&app), app.session.journal.len(), undo_steps(&app)), (1.0, journal, undo), "nothing ran"); + assert_eq!(app.ui.status, steps); + assert!(app.ui.recording.is_some(), "the recording goes on"); + assert!(app.run("effect.list", json!({})).is_ok(), "a command that is not recorded still runs"); + let find = |select: bool| json!({"minWidth": 0, "select": select}); + assert_eq!(app.run("document.find", find(true)).unwrap_err(), steps, "Find Objects that selects is recorded"); + assert!(app.run("document.find", find(false)).is_ok()); + let batch = json!({"commands": [{"command": "object.move", "params": moved.clone()}]}); + assert_eq!(app.run("command.batch", batch).unwrap_err(), steps, "a batch is recorded"); + assert_eq!(x(&app), 1.0); + assert_eq!(app.session.journal.len(), journal); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[1].steps.len(), files::MAX_ACTION_STEPS); + assert_eq!(app.ui.action_sets[0].actions[1].steps.last(), Some(&("object.move".to_string(), moved))); + 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"); + } + } + + /// An ordinary command whose step would give the set of the action it is recorded into a file + /// larger than 8 MiB does not run, and one that makes the file exactly 8 MiB does. + #[test] + fn recorded_commands_keep_an_action_set_file_within_8_mib() { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + 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(); + let added = files::step_bytes("object.move", &moved); + app.ui.action_sets[0].actions[0].steps = vec![pad(room - added)]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved.clone()).unwrap(); + assert_eq!(x(&app), 1.0, "the step that fills the file runs"); + let (journal, undo) = (app.session.journal.len(), undo_steps(&app)); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run("object.move", moved).unwrap_err(), size); + assert_eq!((x(&app), app.session.journal.len(), undo_steps(&app)), (1.0, journal, undo), "nothing ran"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.ui.status.clear(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "", "Stop keeps every recorded step"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(saved.len(), files::MAX_SET_FILE); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[0].steps.len(), 2, "and loads again"); + } + + /// A command whose step fits by its params but not with the values it adds to its step as it + /// runs (Scale's Scale Strokes & Effects and Scale Corners) keeps its edit, the status bar names + /// the limit, the next recorded command does not run, and Stop Playing/Recording leaves the step + /// out. + #[test] + fn a_recorded_command_whose_noted_values_do_not_fit_is_left_out() { + let (mut app, x) = app_with_three_moves(); + let scaled = json!({"sx": 200}); + app.run("object.scale", scaled.clone()).unwrap(); + let noted = app.session.journal.last().unwrap().1.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert!(files::step_bytes("object.scale", ¬ed) > files::step_bytes("object.scale", &scaled), "Scale notes values: {noted}"); + 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 the step as given, not for the step as recorded. + app.ui.action_sets[0].actions[0].steps = vec![pad(room - files::step_bytes("object.scale", &scaled))]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + let size = "the action set would be larger than 8 MiB"; + app.run("object.scale", scaled).unwrap(); + assert_eq!(app.ui.status, size); + let scaled_x = x(&app); + assert_ne!(scaled_x, 0.0, "the scale stayed"); + assert_eq!(app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap_err(), size, "no later command runs"); + assert_eq!(x(&app), scaled_x); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "the scale's step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// 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. + #[test] + fn a_deleted_recording_action_does_not_limit_recorded_commands() { + let (mut app, x) = app_with_three_moves(); + let gone = Action::new("Gone", vec![]); + let id = gone.id; + app.ui.action_sets[0].actions.push(gone); + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(id); + app.ui.action_sets[0].actions.retain(|a| a.id != id); + 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)]; + let before = x(&app); + app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap(); + assert_ne!(x(&app), before, "the move ran"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no action is added"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// A stop that playback reaches while a full action records is not recorded: the status bar shows + /// the reason, and the playback still waits at the stop. + #[test] + fn a_stop_reached_while_recording_a_full_action_is_left_out() { + let (mut app, _) = app_with_a_stop(false); + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 1..files::MAX_ACTION_STEPS { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + assert_eq!(app.ui.status, "the action already has 10,000 steps"); + 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("command.batch")); + } + + /// Steps that reach the journal without a command's check are left out by Stop Playing/Recording + /// past either limit, and the status bar names the limit. + #[test] + fn stop_leaves_out_recorded_steps_past_the_limits() { + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 0..files::MAX_ACTION_STEPS + 5 { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "the action already has 10,000 steps"); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS); + + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.run(START_RECORDING, json!({})).unwrap(); + app.session.journal.push(pad(files::MAX_SET_FILE / 2)); + app.session.journal.push(pad(files::MAX_SET_FILE / 2)); + app.session.journal.push(pad(0)); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "the action set would be larger than 8 MiB"); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), 1); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// An exclusion that would give a set a file larger than 8 MiB is refused for a set, an action, + /// a step and the other steps of an action, and leaves every exclusion as it was. One that + /// fills the file exactly, one that changes nothing and an inclusion still work, also in a set + /// that is already larger than 8 MiB. + #[test] + fn excluding_steps_keeps_an_action_set_file_within_8_mib() { + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions.push(Action::new("Pad", vec![pad(0)])); + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + // Room for two more excluded steps. + app.ui.action_sets[0].actions[1].steps = vec![pad(room - 2 * files::EXCLUDED_BYTES)]; + let size = "the action set would be larger than 8 MiB"; + let include = |app: &mut VectorcraftApp, p: Value| app.run(INCLUDE_STEPS, p); + let excluded = |app: &VectorcraftApp| app.ui.action_sets[0].actions.iter().map(|a| a.excluded_steps.clone()).collect::>(); + let none = vec![BTreeSet::new(), BTreeSet::new()]; + assert_eq!(include(&mut app, json!({"set": 0, "included": false})).unwrap_err(), size); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "included": false})).unwrap_err(), size); + assert_eq!(excluded(&app), none, "nothing changed"); + // Two steps fill the file to 8 MiB. + include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false, "others": true})).unwrap(); + assert_eq!(excluded(&app), [BTreeSet::from([1, 2]), BTreeSet::new()]); + assert_eq!(files::write_set(&app.ui.action_sets[0]).len(), files::MAX_SET_FILE); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false})).unwrap_err(), size); + assert_eq!(include(&mut app, json!({"set": 0, "action": 1, "step": 0, "included": false})).unwrap_err(), size); + assert!(include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": false})).is_ok(), "no change"); + include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": true})).unwrap(); + include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false})).unwrap(); + assert_eq!(excluded(&app), [BTreeSet::from([0, 2]), BTreeSet::new()]); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + + // A set already larger than 8 MiB can still include its steps. + app.ui.action_sets[0].actions[1].steps[0] = pad(room); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap_err(), size); + include(&mut app, json!({"set": 0, "included": true})).unwrap(); + assert_eq!(excluded(&app), none); + + // The steps a recording adds to the set count too. + let (mut app, _) = app_with_three_moves(); + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.run(START_RECORDING, json!({})).unwrap(); + app.session.journal.push(pad(room - files::step_bytes("object.move", &pad(0).1))); + assert_eq!(include(&mut app, json!({"set": 0, "included": false})).unwrap_err(), size); + assert!(app.ui.action_sets[0].actions[0].excluded_steps.is_empty()); + } + + /// Adds an action to set `set` whose step makes the set's file `short` bytes short of 8 MiB. + fn pad_set_to(app: &mut VectorcraftApp, set: usize, short: usize) { + let pad = |n: usize| ("object.move".to_string(), json!({"pad": "x".repeat(n)})); + let s = &mut app.ui.action_sets[set]; + s.actions.push(Action::new("Pad", vec![pad(0)])); + let room = files::MAX_SET_FILE - files::write_set(s).len(); + s.actions.last_mut().unwrap().steps[0] = pad(room - short); + assert_eq!(files::write_set(s).len(), files::MAX_SET_FILE - short); + } + + /// `n` actions with no steps, named A0, A1 … + fn empty_actions(n: usize) -> Vec { + (0..n).map(|i| Action::new(format!("A{i}"), vec![])).collect() + } + + /// A recorded batch that edits another document, and whose values make its step too large only + /// as it runs, keeps its edits in both documents: the status bar names the limit, the next + /// recorded command does not run, and Stop Playing/Recording leaves the batch's step out. + #[test] + fn a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows() { + // Three moves' document, a move as recorded there, and a second document that is active. + let two_documents = || { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + (app, x, moved) + }; + let batch = json!({"commands": [ + {"command": "document.activate", "params": {"index": 0}}, + {"command": "object.scale", "params": {"sx": 200}}, + ]}); + let (mut app, _, _) = two_documents(); + app.run("command.batch", batch.clone()).unwrap(); + let noted = app.session.journal.last().unwrap().1.clone(); + // The batch as the app runs it (with the active artboard) before the scale notes its values. + let mut sent = noted.clone(); + sent["commands"][1]["params"] = json!({"sx": 200}); + let given = files::step_bytes("command.batch", &sent); + assert!(files::step_bytes("command.batch", ¬ed) > given, "the scale notes values: {noted}"); + + let (mut app, x, moved) = two_documents(); + pad_set_to(&mut app, 0, given); + let into = app.ui.action_sets[0].actions.last().unwrap().id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("command.batch", batch).unwrap(); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.ui.status, size); + let scaled = x(&app); + assert_ne!(scaled, 0.0, "the scale stayed"); + assert_eq!(app.run("object.move", moved).unwrap_err(), size, "no later command runs"); + assert_eq!(x(&app), scaled); + let pad = app.ui.action_sets[0].actions.last().unwrap().steps.len(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), pad, "the batch's step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// Set Options… renaming the set that an action records into has the next recorded command + /// measure the set's file again: a step that fit before the longer name does not run after it. + #[test] + fn a_set_renamed_while_it_records_is_measured_again() { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let added = files::step_bytes("object.move", &moved); + pad_set_to(&mut app, 0, 2 * added + 3); + let into = app.ui.action_sets[0].actions.last().unwrap().id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved.clone()).unwrap(); + crate::dialogs::action_set_options::open(&mut app, 0); + let longer = format!("{} 1234", app.ui.action_sets[0].name); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(longer)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.action_sets[0].name, longer); + assert_eq!(app.run("object.move", moved).unwrap_err(), "the action set would be larger than 8 MiB"); + assert_eq!(x(&app), 1.0, "only the first move ran"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// Steps recorded into an action are included in playback, also when the action holds an + /// exclusion past its last step, as saved preferences can; the set's file stays within 8 MiB. + #[test] + fn steps_recorded_after_an_exclusion_past_the_last_step_are_included() { + let (mut app, _) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let added = files::step_bytes("object.move", &moved); + pad_set_to(&mut app, 0, added); + let pad = app.ui.action_sets[0].actions.last_mut().unwrap(); + pad.excluded_steps.insert(1); + let into = pad.id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let pad = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!((pad.steps.len(), pad.excluded_steps.len()), (2, 0), "the recorded step is included"); + assert_eq!(files::write_set(&app.ui.action_sets[0]).len(), files::MAX_SET_FILE); + } + + /// Duplicate copies no action or set when the copy would give a file larger than 8 MiB or a set + /// of more than 1,000 actions, and still copies one that fits. + #[test] + fn duplicate_keeps_action_set_files_within_their_limits() { + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 0); + let size = "the action set would be larger than 8 MiB"; + let names = set_names(&app); + assert_eq!(app.run(DUPLICATE, json!({"set": 1, "action": 0})).unwrap_err(), size); + assert_eq!(app.run(DUPLICATE, json!({"set": 1})).unwrap_err(), size, "the copy's name is longer"); + assert_eq!((set_names(&app), app.ui.action_sets[1].actions.len()), (names, 3), "nothing was copied"); + app.ui.action_sets[2].actions = empty_actions(1000); + let many = "the action set file holds more than 1,000 actions"; + assert_eq!(app.run(DUPLICATE, json!({"set": 2, "action": 0})).unwrap_err(), many); + assert_eq!(app.ui.action_sets[2].actions.len(), 1000); + app.ui.action_sets[2].actions.pop(); + app.run(DUPLICATE, json!({"set": 2, "action": 0})).unwrap(); + app.run(DUPLICATE, json!({"set": 2})).unwrap(); + assert_eq!((app.ui.action_sets[2].actions.len(), app.ui.action_sets[3].actions.len()), (1000, 1000)); + } + + /// New Action and Begin Recording add no action and start no recording when the action would + /// give its set more than 1,000 actions or a file larger than 8 MiB, and New Action adds one that + /// fits. + #[test] + fn new_action_keeps_an_action_set_file_within_its_limits() { + let mut app = app_with_sets(); + app.ui.action_sets[2].actions = empty_actions(1000); + let many = "the action set file holds more than 1,000 actions"; + assert_eq!(app.run(NEW_ACTION, json!({"set": 2, "name": "More"})).unwrap_err(), many); + assert_eq!(app.run(START_RECORDING, json!({"set": 2})).unwrap_err(), many, "Begin Recording too"); + pad_set_to(&mut app, 1, 200); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(NEW_ACTION, json!({"set": 1, "name": "x".repeat(300)})).unwrap_err(), size); + assert!(app.ui.recording.is_none(), "no recording started"); + assert_eq!((app.ui.action_sets[1].actions.len(), app.ui.action_sets[2].actions.len()), (3, 1000)); + app.run(NEW_ACTION, json!({"set": 1, "name": "More"})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[1].actions.len(), 4); + assert!(files::write_set(&app.ui.action_sets[1]).len() <= files::MAX_SET_FILE); + } + + /// Renaming an action, Action Options and Set Options change nothing when the change would give + /// the set a file larger than 8 MiB, with the steps a recording adds to the set; a change that + /// fills the file exactly, or that makes a set already past 8 MiB smaller, is made. New Set adds + /// no set whose file would be larger than 8 MiB. + #[test] + fn renaming_keeps_an_action_set_file_within_8_mib() { + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 2); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge Far"})).unwrap_err(), size); + assert_eq!(app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "color": "red"})).unwrap_err(), size); + assert_eq!(app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "key": 2})).unwrap_err(), size); + let nudge = &app.ui.action_sets[1].actions[0]; + assert_eq!((nudge.name.as_str(), nudge.key, nudge.color.as_deref()), ("Nudge", None, None), "nothing changed"); + crate::dialogs::action_set_options::open(&mut app, 1); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mine and More")); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), size); + assert!(app.ui.dialog.is_some(), "Set Options stays open"); + assert_eq!(app.ui.action_sets[1].name, "Mine"); + app.ui.dialog = None; + app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge++"})).unwrap(); + assert_eq!(files::write_set(&app.ui.action_sets[1]).len(), files::MAX_SET_FILE, "the file is exactly 8 MiB"); + app.ui.action_sets[1].actions[1].name = "x".repeat(100); + app.run(RENAME_ACTION, json!({"set": 1, "action": 1, "name": "Nudge Again"})).unwrap(); + assert_eq!(app.ui.action_sets[1].actions[1].name, "Nudge Again", "a set past 8 MiB gets smaller"); + + crate::dialogs::action_set_options::open_new(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("x".repeat(files::MAX_SET_FILE))); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), size); + assert_eq!(app.ui.action_sets.len(), 3, "no set was added"); + app.ui.dialog = None; + + // The steps a recording adds to the set count too. + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 1000); + app.run(START_RECORDING, json!({"set": 1})).unwrap(); + app.session.journal.push(("object.move".to_string(), json!({"pad": "x".repeat(1000)}))); + assert_eq!(app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge+"})).unwrap_err(), size); + app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudg"})).unwrap(); + } + + /// Load Actions… adds no set that Save Actions… would write as a file larger than 8 MiB: a file + /// with less white space, a file whose set name gets a number, and another app's action set whose + /// names take more bytes in the file. + #[test] + fn load_actions_refuses_a_set_that_would_save_larger_than_8_mib() { + let mut app = app_with_sets(); + let size = "the action set would be larger than 8 MiB"; + let wide = ActionSet::new("Wide", vec![Action::new("Zeros", vec![("object.move".into(), json!({"p": vec![0; 600_000]}))])]); + let saved = files::write_set(&wide); + let packed = serde_json::to_string(&serde_json::from_str::(&saved).unwrap()).unwrap(); + assert!(packed.len() <= files::MAX_SET_FILE && saved.len() > files::MAX_SET_FILE); + assert_eq!(app.run(LOAD_SET, json!({ "data": packed })).unwrap_err(), size); + + let mut full = app_with_sets(); + full.ui.action_sets[1].actions.clear(); + pad_set_to(&mut full, 1, 0); + let mine = files::write_set(&full.ui.action_sets[1]); + assert_eq!(app.run(LOAD_SET, json!({ "data": mine })).unwrap_err(), size, "Mine 2 is longer"); + let yours = mine.replacen("\"Mine\"", "\"Your\"", 1); + assert_eq!(app.run(LOAD_SET, json!({ "data": yours })).unwrap()["name"], "Your"); + + let name = "\u{1}".repeat(1024); + let steps = [("Group", true); 40]; + let text = aia_text("Names", &vec![(name.as_str(), steps.as_slice()); 1000]); + assert!(text.len() <= files::MAX_SET_FILE); + assert_eq!(app.run(LOAD_SET, json!({ "data": text })).unwrap_err(), size); + assert_eq!(set_names(&app), ["Default Actions", "Mine", "Other", "Your"]); + } + /// A step inserted from the menu saves and loads as the command and params it holds, with its /// place and exclusion. #[test] diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 2e1385a7d8..fd8b40fbd7 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -31,7 +31,7 @@ pub const ACTIONS_EXT: &str = "vcactions"; pub(super) const MAX_SET_FILE: usize = 8 << 20; /// The most actions [`read_set`] reads from one file. -const MAX_SET_ACTIONS: usize = 1000; +pub(super) const MAX_SET_ACTIONS: usize = 1000; /// The most steps [`read_set`] reads in one action. pub(super) const MAX_ACTION_STEPS: usize = 10_000; @@ -74,6 +74,51 @@ fn is_false(value: &bool) -> bool { !value } +/// A step as [`write_set`] writes it when it is included: [`FileStep`] without `excluded`. +#[derive(Serialize)] +struct IncludedStep<'a> { + command: &'a str, + params: &'a Value, +} + +/// The bytes that excluding one step adds to the text [`write_set`] writes: a comma, a line +/// break, the indent and `"excluded": true`. +pub(super) const EXCLUDED_BYTES: usize = 28; + +/// The bytes more that a step adds to the text [`write_set`] writes as an action's first step than +/// [`step_bytes`] counts: a line break on each side of the step and the indent of the closing +/// bracket, in place of a comma and a line break. +pub(super) const FIRST_STEP_BYTES: usize = 6; + +/// The bytes that the included step `(command, params)` adds to the text [`write_set`] writes when +/// it goes after the last step of an action. As the action's first step it adds +/// [`FIRST_STEP_BYTES`] more. +pub(super) fn step_bytes(command: &str, params: &Value) -> usize { + /// Counts the bytes and the line breaks written to it. + #[derive(Default)] + struct Count { + bytes: usize, + breaks: usize, + } + impl std::io::Write for Count { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.bytes += buf.len(); + self.breaks += buf.iter().filter(|&&b| b == b'\n').count(); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut count = Count::default(); + if serde_json::to_writer_pretty(&mut count, &IncludedStep { command, params }).is_err() { + return MAX_SET_FILE + 1; + } + // In the file every line of the step is indented by 8 more spaces, and a comma and a line break + // come before it. + count.bytes + 8 * (count.breaks + 1) + 2 +} + /// The params of the stops in a step: a stop's own, or those of the stops a `command.batch` step /// holds, which playback runs as its own steps. fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { @@ -329,6 +374,8 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { } let AiaLoad { mut set, skipped, left_out } = aia_set(&read, "Actions"); set.name = free_set_name(&app.ui.action_sets, &set.name); + // The set must save as a file that Load Actions… reads, which can be larger than the file read. + super::edit_keeps_set_loadable(app, None, &set)?; let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); if left_out > 0 { @@ -350,14 +397,17 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { /// same name as one in the panel gets the next free number (`Print 2`). Loaded actions keep their /// function keys. The current set and the other sets stay as they are → /// `{set: index, name, actions: n}`. Where the host can read part of a file (the desktop), a file -/// larger than 8 MiB is refused after its first 8 MiB and one byte are read. +/// larger than 8 MiB is refused after its first 8 MiB and one byte are read. A set that Save +/// Actions… would write as a file Load Actions… refuses (larger than 8 MiB, as a file with less +/// white space or a name that gets a number can give) is refused too, and no set is added. /// /// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its /// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), /// with no params; a step that is off in the file is excluded from playback. A set with no name is /// called after the file, else `Actions`. The other steps are left out and listed, and the status /// bar says how many → `{set, name, actions, skipped: [{action, step}] (at most 1,000), leftOut: n}`. -/// Such a file that `actionSet.read` marks `truncated` is refused, and no set is added. +/// Such a file that `actionSet.read` marks `truncated` is refused, and so is one whose set Save +/// Actions… would write larger than 8 MiB; then no set is added. pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let (text, stem) = match p.get("data") { Some(Value::String(data)) => { @@ -398,6 +448,8 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { }; let mut set = read_set(&text, if stem.is_empty() { "Actions" } else { &stem })?; set.name = free_set_name(&app.ui.action_sets, &set.name); + // A number added to the name can make a file at the limit larger than it. + super::edit_keeps_set_loadable(app, None, &set)?; let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); Ok(json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions })) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2f834a272c..adf6cacf3b 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -450,10 +450,10 @@ impl Field { /// Type tool edits, the panel shows, nothing takes the focus and the command fails. → `{field}`. pub(crate) fn highlight(app: &mut VectorcraftApp, field: Field) -> Result { // `window.panel` would hide a floating panel in front of its window, or an open flyout. - if crate::panel_docking::showing(&app.ui, "character") { + if crate::panel_docking::visible(&app.ui, "character") { app.run("window.panel.activate", json!({"panel": "character"}))?; } else { - app.run("window.panel", json!({"panel": "character"}))?; + crate::panel_docking::reveal_panel(app, "character")?; } if text_style(app).is_none() { app.character_focus = None; diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index 4d6bf05ec1..e450da63aa 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -1,5 +1,5 @@ //! Document Info panel: the document's setup and what it contains (objects by kind, graphic -//! styles, brushes, spot colors, objects painted with patterns and with gradients, symbols, fonts and their files, +//! styles, brushes, objects painted with spot colors, with patterns and with gradients, symbols, fonts and their files, //! linked and embedded images, repeat objects), for the whole document or the selection only. The flyout picks one category (or all) //! and saves the text report (`docInfo.save`, the report File → Package writes too). @@ -192,6 +192,7 @@ mod tests { && menu_text.contains("Brushes") && menu_text.contains("Repeat Objects") && menu_text.contains("Gradient Objects") + && menu_text.contains("Spot Color Objects") && menu_text.contains("Save…"), "{menu_text}" ); @@ -205,6 +206,17 @@ mod tests { show(app, ui); }); assert!(gradients.contains("Gradient Objects (1)") && gradients.contains("Glow") && gradients.contains("Radial gradient"), "{gradients}"); + // Spot Color Objects lists the objects painted with a spot color by name. + let spot = json!({"model": "cmyk", "c": 0.0, "m": 0.8, "y": 0.6, "k": 0.0}); + app.session.execute("swatch.new", &json!({"name": "Signal Red", "color": spot, "spot": true})).unwrap(); + let badge = app.session.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("paint.setFill", &json!({"ids": [badge], "swatch": "Signal Red"})).unwrap(); + app.session.execute("layer.setProps", &json!({"id": badge, "name": "Badge"})).unwrap(); + let spots = crate::tests_labels::painted_text(&mut app, |app, ui| { + set_pstate(ui.ctx(), "docinfo-category", "spotColors".to_string()); + show(app, ui); + }); + assert!(spots.contains("Spot Color Objects (1)") && spots.contains("Badge") && spots.contains("Signal Red"), "{spots}"); let filtered = crate::tests_labels::painted_text(&mut app, |app, ui| { set_pstate(ui.ctx(), "docinfo-category", "fonts".to_string()); show(app, ui); @@ -222,6 +234,7 @@ mod tests { let report = String::from_utf8(written.borrow().clone()).unwrap(); assert!(report.starts_with("Document Info: ") && report.contains("\nFONT DETAILS\n") && report.contains("\nREPEAT OBJECTS\n"), "{report}"); assert!(report.contains("\nGRADIENT OBJECTS\nGlow: Radial gradient\n"), "{report}"); + assert!(report.contains("\nSPOT COLOR OBJECTS\nBadge: Signal Red\n"), "{report}"); } /// The report the panel menu's Save… writes with category `category` shown ("" for All diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 9192aa5f0b..7c0a18d14f 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -1,8 +1,14 @@ //! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own -//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the -//! selected text objects). The character selected with the Type tool is highlighted in the grid. -//! Show › Alternates for Current Selection lists that character's alternate glyphs instead, and a -//! double-click draws the character with one of them. +//! outlines; Show › Entire Font adds the glyphs no character maps, by glyph id. Double-click +//! inserts the character at the Type tool's caret (or appends it to the selected text objects). +//! The character selected with the Type tool is highlighted in the grid. +//! Show › Alternates for Current Selection lists that character's alternate glyphs instead (as it +//! stands in its word), and a double-click applies the OpenType feature that draws one to that +//! character alone, so the text stays real text that joins its neighbors. A double-click on a +//! glyph no character maps in Entire Font does the same when the selected character's font gives +//! that glyph as one of its alternates in its word. A glyph no feature reaches whose name does +//! not belong to another letter is applied as a glyph choice, which may not join. Other glyphs +//! are drawn dimmed, and their tooltips say why they cannot be applied to the selection. use std::collections::HashMap; @@ -192,23 +198,26 @@ fn selected_target(app: &VectorcraftApp) -> Option> { None } -/// Insert `ch` at the Type tool's caret, else append it to the selected text. -fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) { +/// Insert `ch` at the Type tool's caret, else append it to the selected text; whether it succeeded. +fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) -> bool { let s = ch.to_string(); if app.session.tool_wants_text() { let v = app.view_info(); let target_before = crate::ime::target(&mut app.session, v); let before = crate::ime::stamp(&mut app.session, v); let r = app.session.tool_text(&s, v); + let inserted = r.is_ok(); match &r { Err(e) => app.status(e.to_string()), Ok(_) => crate::ime::note_application_edit(app, ctx, v, target_before, before), } app.sync_views(); + inserted } else if super::character::text_style(app).is_some() { - app.run("type.insert", json!({"text": s})).ok(); + app.run("type.insert", json!({"text": s})).is_ok() } else { app.ui.status = "Select text or click into text with the Type tool to insert glyphs".into(); + false } } @@ -235,19 +244,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { font_row(app, ui, &family, &style, w); return; } - let Some(face) = db.face(&family, &style) else { + // The same family and style can have a different face id at the selection's version or axes. + // Use that instance for both the previews and Fit's applicability check. + let face = selected_target(app) + .filter(|t| t.style.font_family.eq_ignore_ascii_case(&family) && t.style.font_style.eq_ignore_ascii_case(&style)) + .and_then(|t| db.style_face(t.style)) + .or_else(|| db.face(&family, &style)); + let Some(face) = face else { ui.label(egui::RichText::new(tl!("No fonts are available.")).color(t.text_dim)); return; }; let chars: Vec<(char, u32)> = face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect(); + // Entire Font also lists the glyphs no character maps (positional forms, alternates, + // ligatures) by glyph id, after the characters. + let unencoded = if subset == 0 { face.unencoded_glyphs() } else { Default::default() }; + let total = chars.len() + unencoded.len(); let cell = CELL_STEPS[cell_index(app)]; let cols = ((w / cell).floor() as usize).max(1); - let rows = chars.len().div_ceil(cols); + let rows = total.div_ceil(cols); let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; let list_h = widgets::panel_list_height(ui, (ui.available_height() - 64.0).max(cell * 3.0), 33.0); let hover_id = egui::Id::unique("gl-hover"); let selected = selected_char(app); + // What a double-click on an unencoded glyph can do to the selected character. + let fit = (!unencoded.is_empty()).then(|| Fit::of(app, ui, &face)); // The selected character and the rows shown last frame: the grid scrolls to the selected glyph // only when the selection moves to another character, so it still scrolls freely meanwhile. let follow_id = egui::Id::unique("gl-follow"); @@ -259,46 +280,91 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && !shown.contains(&(i / cols)) { let row_h = cell + ui.spacing().item_spacing.y; - area = area.vertical_scroll_offset(((i / cols) as f32 + 0.5) * row_h - list_h * 0.5); + area = area.vertical_scroll_offset((((i / cols) as f32 + 0.5) * row_h - list_h * 0.5).max(0.0)); } - let shown = area.show_rows(ui, cell, rows, |ui, range| { - for r in range.clone() { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - for &(c, gid) in chars.iter().skip(r * cols).take(cols) { - let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); - let chosen = selected == Some(c); - if resp.hovered() { - ui.painter().rect_filled(rect, 2, t.hover); - ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); - } else if chosen { - ui.painter().rect_filled(rect, 2, t.row_selected); + let shown = + area.show_rows(ui, cell, rows, |ui, range| { + for r in range.clone() { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + for k in r * cols..((r + 1) * cols).min(total) { + let (c, gid) = match chars.get(k) { + Some(&(c, gid)) => (Some(c), gid), + None => (None, unencoded.get(k - chars.len()).copied().unwrap_or_default()), + }; + let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); + let chosen = c.is_some() && selected == c; + if resp.hovered() { + ui.painter().rect_filled(rect, 2, t.hover); + ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); + } else if chosen { + ui.painter().rect_filled(rect, 2, t.row_selected); + } + let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) }; + ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); + // A glyph no character maps is dimmed when it cannot go on the selection. + let pick = match (c, &fit) { + (None, Some(fit)) => Some(fit.pick(&face, gid)), + _ => None, + }; + let ink = if matches!(pick, Some(None)) { t.text_disabled } else { t.text_strong }; + let tex = glyph_texture(ui.ctx(), &face, gid, px); + ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), ink); + // The tooltip is made when it is shown: working out why a glyph cannot be + // applied reads the font, which hundreds of cells cannot do each frame. + let resp = resp.on_hover_ui(|ui| { + let tip = match (c, &pick) { + (Some(c), _) => encoded_tip(&face, c, gid), + (None, Some(Some(Pick::Glyph(_)))) => format!( + "{}\n{}\n{}", + unencoded_tip(&face, gid), + tl!("Double-click to replace"), + tl!("No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors.") + ), + (None, Some(Some(_))) => format!("{}\n{}", unencoded_tip(&face, gid), tl!("Double-click to replace")), + (None, Some(None)) => { + let why = fit.as_ref().map(|f| f.why(&face, gid)).unwrap_or_default(); + format!("{}\n{why}", unencoded_tip(&face, gid)) + } + (None, None) => unencoded_tip(&face, gid), + }; + ui.label(tip); + }); + if resp.double_clicked() { + match (c, pick) { + (Some(c), _) => insert_character(app, ui.ctx(), &face, c, gid), + (None, Some(Some(pick))) => { + if let Some(fit) = &fit { + fit.apply(app, pick); + } + } + (None, Some(None)) => { + if let Some(fit) = &fit { + app.status(fit.why(&face, gid)); + } + } + (None, None) => {} + } + } } - let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) }; - ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); - let tex = glyph_texture(ui.ctx(), &face, gid, px); - ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); - let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert"))); - if resp.double_clicked() { - insert(app, ui.ctx(), c); - } - } - }); - } - range - }); + }); + } + range + }); ui.ctx().data_mut(|d| d.insert_temp(follow_id, (selected, shown.inner))); ui.add_space(4.0); - let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id)); + let info = ui.ctx().data(|d| d.get_temp::<(Option, u32)>(hover_id)); + let count = crate::i18n::tn(total as u64, "{n} glyph", "{n} glyphs"); ui.label( egui::RichText::new(match info { - Some((c, g)) => format!( - "U+{:04X} {} · {}", - c as u32, - crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]), - crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs") - ), - None => crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs"), + Some((c, g)) => { + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]); + match c { + Some(c) => format!("U+{:04X} {glyph} · {count}", c as u32), + None => format!("{glyph} · {count}"), + } + } + None => count, }) .size(11.0) .color(t.text_dim), @@ -342,11 +408,36 @@ fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w: }); } +/// One cell of Show › Alternates for Current Selection: a glyph, the feature that lists it, and the +/// feature tag that draws it on the selected character in its word (`salt=2`), when one does. +struct Variant { + gid: u32, + feature: [u8; 4], + apply: Option, + /// The glyph the shaper makes for the character with none of its own alternate features. + default: bool, +} + +/// What a double-click on a variant does to the selected character. +enum Pick { + /// Back to the default glyph: its alternate features off, a glyph choice taken away. + Default, + /// A feature that draws the variant: the text stays real Unicode and its word stays joined. + Feature(String), + /// No feature draws it: it replaces the character's glyph (`text.setGlyphAlternate`), which is + /// shaped apart from its neighbors. + Glyph(u32), +} + /// The grid of Show › Alternates for Current Selection: the alternate glyphs of the Type tool's -/// selected character in the face its text is set in, whatever font the panel browses -/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The default -/// glyph comes first when the font does not list it. The glyph the character is drawn with is -/// highlighted. A double-click draws the character with the glyph (`text.setGlyphAlternate`). +/// selected character as it stands in its word ([`vectorcraft_text::alternates_in_context`]: a +/// medial letter lists its medial form's alternates), in the face the text is set in, whatever font +/// the panel browses, with that face's name under the grid. The glyph the shaper makes for the +/// character with none of its own alternate features comes first, and the glyph the character is +/// drawn with is highlighted. A double-click applies the OpenType feature that draws the glyph to +/// that character alone (`text.setRangeStyle`, `salt=2`), as one undo step, so the text stays real +/// text that joins its neighbors. A glyph no feature draws there replaces the character's glyph +/// instead (`text.setGlyphAlternate`), and its tooltip says that it may not join. fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let t = Tokens::get(ui.ctx()); let db = FontDb::global(); @@ -362,13 +453,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { return note(ui, tl!("The selected character's font is not available.")); }; let (id, byte, ch) = (target.id, target.byte, target.ch); - let (list, drawn) = alternates_and_drawn(app, ui, &face, id, byte, ch); + let (features, has_choice) = (target.style.features.clone(), target.style.glyph_alternate.is_some()); + let found = alternates_found(app, ui, id, byte, face.id()); let font = format!("{} {}", face.family, face.style); - if list.is_empty() { + let Some(found) = found.filter(|f| has_choice || !f.alternates.is_empty()) else { note(ui, tl!("No alternates for the selected character.")); ui.label(egui::RichText::new(font).size(11.0).color(t.text_dim)); return; - } + }; + // The glyphs are drawn from the face that supplied the character's glyph (a fallback face for + // a character the text's font lacks). + let face = db.face_by_id(found.font_id).unwrap_or(face); + let drawn = Some(found.current); + let mut list = vec![Variant { gid: found.default, feature: [0; 4], apply: None, default: true }]; + list.extend(found.alternates.iter().filter(|a| a.gid != found.default).map(|a| Variant { + gid: a.gid, + feature: a.feature, + apply: a.apply.clone(), + default: false, + })); let cell = CELL_STEPS[cell_index(app)]; let cols = ((w / cell).floor() as usize).max(1); let rows = list.len().div_ceil(cols); @@ -387,7 +490,7 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for a in list.iter().skip(r * cols).take(cols) { let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); - let current = drawn.is_some_and(|(font_id, gid)| font_id == face.id() && a.gid == gid); + let current = drawn == Some(a.gid); if resp.hovered() { ui.painter().rect_filled(rect, 2, t.hover); } else if current { @@ -398,10 +501,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let tex = glyph_texture(ui.ctx(), &face, a.gid, px); ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &a.gid.to_string())]); - let glyph = if a.feature == [0; 4] { glyph } else { format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)) }; - let resp = resp.on_hover_text(format!("{glyph}\n{}", tl!("Double-click to replace"))); + let glyph = match (&a.apply, a.default) { + (_, true) => glyph, + (Some(tag), _) => format!("{glyph} ({tag})"), + (None, _) => format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)), + }; + let mut tip = format!("{glyph}\n{}", tl!("Double-click to replace")); + if !a.default && a.apply.is_none() { + tip.push('\n'); + tip.push_str(tl!( + "No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors." + )); + } + let resp = resp.on_hover_text(tip); if resp.double_clicked() { - pick = Some(a.gid); + pick = Some(match (&a.apply, a.default) { + (_, true) => Pick::Default, + (Some(tag), _) => Pick::Feature(tag.clone()), + (None, _) => Pick::Glyph(a.gid), + }); } } }); @@ -410,28 +528,227 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { ui.add_space(4.0); let count = crate::i18n::tn(list.len() as u64, "{n} glyph", "{n} glyphs"); ui.label(egui::RichText::new(format!("{font} · {count}")).size(11.0).color(t.text_dim)); - if let Some(gid) = pick { + if let Some(pick) = pick { super::character::end_typing(app); - if let Err(e) = app.run("text.setGlyphAlternate", json!({"id": id.0, "start": byte, "gid": gid})) { - app.status(e.to_string()); + apply_alternate(app, id, (byte, byte + ch.len_utf8()), &features, has_choice, pick); + } +} + +/// Do what a double-click on a variant says to the character at `bytes` of text `id`, whose style +/// has `features` and, when `has_choice`, a glyph choice: one command, so one undo step. A feature +/// goes through `text.setRangeStyle` on exactly the character (its other alternate features are +/// turned off, a glyph choice is taken away); a glyph no feature draws through +/// `text.setGlyphAlternate`. The default glyph, or a variant the character already has, changes +/// nothing. +fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize), features: &[String], has_choice: bool, pick: Pick) { + let (cmd, params) = match pick { + Pick::Glyph(gid) => ("text.setGlyphAlternate", json!({"id": id.0, "start": bytes.0, "gid": gid})), + Pick::Default | Pick::Feature(_) => { + let tag = if let Pick::Feature(tag) = &pick { Some(tag.as_str()) } else { None }; + let changes = vectorcraft_text::alternate_changes(features, tag); + if changes.is_empty() && !has_choice { + return; + } + let mut params = json!({"id": id.0, "start": bytes.0, "end": bytes.1}); + if !changes.is_empty() { + params["featureChanges"] = json!(changes); + } + if has_choice { + params["glyphAlternate"] = serde_json::Value::Null; + } + ("text.setRangeStyle", params) + } + }; + if let Err(e) = app.run(cmd, params) { + app.status(e.to_string()); + } +} + +/// The Type tool's selected character, and what its word offers, for a double-click on a glyph no +/// character maps in Show › Entire Font ([`Fit::pick`]). +struct Fit { + /// The selected character: its text, where it is and how it is styled. `None` without one. + target: Option, + /// The id of the face the panel browses; its glyph ids are what is applied. + face_id: u32, +} + +struct FitTarget { + id: NodeId, + byte: usize, + ch: char, + features: Vec, + has_choice: bool, + /// The family the selected text is set in, and whether the app has it. + family: String, + available: bool, + /// The alternates of the character in its word, from the face of its text. + found: Option, +} + +impl Fit { + fn of(app: &VectorcraftApp, ui: &Ui, face: &vectorcraft_text::FontFace) -> Self { + let db = FontDb::global(); + let target = selected_target(app).map(|t| { + let text_face = db.style_face(t.style).filter(|_| db.has_family(&t.style.font_family)); + let found = text_face.as_ref().and_then(|f| alternates_found(app, ui, t.id, t.byte, f.id())); + FitTarget { + id: t.id, + byte: t.byte, + ch: t.ch, + features: t.style.features.clone(), + has_choice: t.style.glyph_alternate.is_some(), + family: t.style.font_family.clone(), + available: text_face.is_some(), + found, + } + }); + Fit { target, face_id: face.id() } + } + + /// What a double-click on glyph `gid` does to the selected character: the way to draw it with + /// that glyph, when it is the character's default glyph or one of its alternates as it stands + /// in its word, in the face the selected text is set in; or a glyph no feature reaches that + /// the font's naming does not give to another letter (`A.salt` for `A`, `s.ss1` in a font + /// with no `ss01`), which is drawn as a glyph choice. `None` when it is not. + fn pick(&self, face: &vectorcraft_text::FontFace, gid: u32) -> Option { + let t = self.target.as_ref().filter(|t| t.available)?; + let found = t.found.as_ref().filter(|f| f.font_id == self.face_id)?; + if gid == found.default { + return Some(Pick::Default); + } + if let Some(a) = found.alternates.iter().find(|a| a.gid == gid) { + return Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature)); + } + face.is_unlisted_alternate(t.ch, gid).then_some(Pick::Glyph(gid)) + } + + /// Why [`Self::pick`] finds nothing for glyph `gid` of `face`, in the user's words: what the + /// glyph belongs to (its letter and place in a word, from the font's own substitutions) and what + /// the selection is, or that nothing is selected. + fn why(&self, face: &vectorcraft_text::FontFace, gid: u32) -> String { + let Some(t) = &self.target else { return tl!("Select one character with the Type tool first.").to_string() }; + if !t.available { + return tl!("The selected character's font is not available.").to_string(); + } + let name = glyph_label(face, gid); + if let Some(found) = t.found.as_ref().filter(|f| f.font_id != self.face_id) { + let other = FontDb::global().face_by_id(found.font_id).map_or_else(|| t.family.clone(), |f| format!("{} {}", f.family, f.style)); + return crate::i18n::fmt( + tl!("{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font})."), + &[("glyph", &name), ("font", &format!("{} {}", face.family, face.style)), ("text_font", &other)], + ); + } + let (sel, sel_code) = (t.ch.to_string(), format!("{:04X}", t.ch as u32)); + let not_alternate = || { + crate::i18n::fmt( + tl!("{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word."), + &[("glyph", &name), ("sel", &sel), ("code", &sel_code)], + ) + }; + let Some(origin) = face.glyph_origin(gid) else { return not_alternate() }; + let place = match origin.position() { + Some(vectorcraft_text::JoiningPosition::Initial) => Some(tl!("at the start of a word")), + Some(vectorcraft_text::JoiningPosition::Medial) => Some(tl!("in the middle of a word")), + Some(vectorcraft_text::JoiningPosition::Final) => Some(tl!("at the end of a word")), + Some(vectorcraft_text::JoiningPosition::Isolated) => Some(tl!("standing alone")), + None => None, + }; + let (Some(place), true) = (place, origin.ch != t.ch) else { return not_alternate() }; + crate::i18n::fmt( + tl!("{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code})."), + &[ + ("glyph", &name), + ("base", &origin.ch.to_string()), + ("base_code", &format!("{:04X}", origin.ch as u32)), + ("place", place), + ("sel", &sel), + ("code", &sel_code), + ], + ) + } + + /// Apply `pick` to the selected character, as a double-click on a variant of Show › Alternates + /// for Current Selection does. + fn apply(&self, app: &mut VectorcraftApp, pick: Pick) { + let Some(t) = &self.target else { return }; + super::character::end_typing(app); + apply_alternate(app, t.id, (t.byte, t.byte + t.ch.len_utf8()), &t.features, t.has_choice, pick); + } +} + +/// `name` of glyph `gid`, or `gid N`. +fn glyph_label(face: &vectorcraft_text::FontFace, gid: u32) -> String { + face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}")) +} + +/// The letter to insert in place of character `c`, an Arabic presentation form: the letter whose +/// glyph the font substitutes with `init`, `medi`, `fina` or `isol` to get `c`'s glyph. The +/// presentation form itself is a fixed shape that neither joins its neighbors nor is joined by +/// them; the letter takes the form its place in the word asks for, so the word stays joined. +/// `None` when `c` is not such a form in this font. +fn base_letter(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> Option { + if !matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}') { + return None; + } + let origin = face.glyph_origin(gid)?; + origin.position()?; + (origin.ch != c).then_some(origin.ch) +} + +/// The tooltip of an encoded glyph (a character): its code point and glyph, and what a +/// double-click inserts. +fn encoded_tip(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> String { + let head = format!("U+{:04X} {c} (glyph {gid})", c as u32); + match base_letter(face, c, gid) { + Some(base) => format!( + "{head}\n{}", + crate::i18n::fmt( + tl!("Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does."), + &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))] + ) + ), + None => format!("{head}\n{}", tl!("Double-click to insert")), + } +} + +/// Double-click on character `c` of the grid: insert it, or its letter when it is an Arabic +/// presentation form ([`base_letter`]), which the status says. +fn insert_character(app: &mut VectorcraftApp, ctx: &egui::Context, face: &vectorcraft_text::FontFace, c: char, gid: u32) { + match base_letter(face, c, gid) { + Some(base) => { + if insert(app, ctx, base) { + app.status(crate::i18n::fmt( + tl!("Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position."), + &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))], + )); + } + } + None => { + insert(app, ctx, c); } } } -/// The alternates of `ch` in `face` and the face id and glyph the text object `id` draws the -/// character at `byte` with. Both are kept in egui's temporary data until the document, the -/// character or the face changes, as [`super::character`] keeps the kerning at the caret. -fn alternates_and_drawn( - app: &VectorcraftApp, - ui: &Ui, - face: &vectorcraft_text::FontFace, - id: NodeId, - byte: usize, - ch: char, -) -> (Vec, Option<(u32, u32)>) { - type Found = (Vec, Option<(u32, u32)>); +/// The tooltip of a glyph no character maps (Show › Entire Font): its name from the font, `gid N` +/// when it has none, its id, and the feature that gives it, if one does. +fn unencoded_tip(face: &vectorcraft_text::FontFace, gid: u32) -> String { + let name = face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}")); + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &gid.to_string())]); + match face.feature_reaching(gid) { + Some(feature) => format!("{name} ({glyph}) · {}", String::from_utf8_lossy(&feature)), + None => format!("{name} ({glyph})"), + } +} + +/// The alternates of the character at `byte` of text `id` in its word, and the glyph it is drawn +/// with ([`vectorcraft_text::alternates_in_context`]). Kept in egui's temporary data until the +/// document, the character or the face changes, as [`super::character`] keeps the kerning at the +/// caret. +fn alternates_found(app: &VectorcraftApp, ui: &Ui, id: NodeId, byte: usize, face_id: u32) -> Option { + type Found = Option; let state = app.session.active(); - let key = state.map(|s| (s.uid, s.revision, id, byte, face.id())); + let key = state.map(|s| (s.uid, s.revision, id, byte, face_id)); let slot = ui.scope_id().with("gl-alternates-found"); if let Some(key) = key && let Some((cached_key, found)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, u32), Found)>(slot)) @@ -439,20 +756,10 @@ fn alternates_and_drawn( { return found; } - let drawn = state - .and_then(|s| s.doc.node(id)) - .and_then(|node| match &node.kind { - NodeKind::Text(text) => Some(vectorcraft_text::layout(FontDb::global(), text)), - _ => None, - }) - .and_then(|layout| layout.glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len))) - .map(|g| (g.font_id, g.gid)); - let mut list = face.alternates(ch); - let default = face.glyph_for(ch); - if !list.is_empty() && default != 0 && !list.iter().any(|a| a.gid == default) { - list.insert(0, vectorcraft_text::GlyphAlternate { gid: default, feature: [0; 4] }); - } - let found = (list, drawn); + let found = state.and_then(|s| s.doc.node(id)).and_then(|node| match &node.kind { + NodeKind::Text(text) => vectorcraft_text::alternates_in_context(FontDb::global(), text, byte), + _ => None, + }); if let Some(key) = key { ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone()))); } @@ -806,6 +1113,163 @@ mod tests { assert_eq!(highlighted(&found, &t), [squares(&found)[5].center()], "the chosen glyph, after b, u2, u4 and u3"); } + /// Double-click the glyph cell at `at`. + fn double_click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) { + double_click_in(ctx, app, at, vec2(280.0, 420.0)); + } + + /// Double-click the glyph cell at `at` in a panel `size` large. + fn double_click_in(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, size: egui::Vec2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Time only moves forward in one egui context. + let mut time = ctx.input(|i| i.time) + 10.0; + let mut timed = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = Some(Rect::from_min_size(Pos2::ZERO, size)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: screen, time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + timed(app, vec![egui::Event::PointerMoved(at)]); + for _ in 0..2 { + timed(app, vec![button(true)]); + timed(app, vec![button(false)]); + } + } + + /// The glyph ids of the text `id`, in the order of its characters. + fn glyph_ids(app: &VectorcraftApp, id: &serde_json::Value) -> Vec { + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + let mut glyphs = vectorcraft_text::layout(FontDb::global(), text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs.iter().map(|g| g.gid).collect() + } + + /// For the selected medial letter of an Arabic word the grid lists that form's alternates, and + /// a double-click applies the feature that draws one to that letter alone, as one undo step: + /// the word keeps its joining forms and the text its characters; undo takes it back. + #[test] + fn a_double_click_applies_a_feature_value_to_one_letter_of_a_joined_word() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let g = |c| u32::from(source_sans_gid(c).expect("a glyph")); + let (mut app, id) = editing("\u{628}\u{62A}\u{646}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let found = shapes(&ctx, &mut app, vec![]); + let cells = squares(&found); + assert_eq!(cells.len(), 4, "the medial teh and its three alternates, not the isolated form's"); + assert_eq!(highlighted(&found, &t), [cells[0].center()], "the form the letter has is first and drawn"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, cells[2].center()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step; status: {}", app.ui.status); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{62A}\u{646}", "the text keeps its characters"); + let features: Vec<(String, Vec)> = text.runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect(); + assert_eq!( + features, + [("\u{628}".to_string(), vec![]), ("\u{62A}".to_string(), vec!["salt=2".to_string()]), ("\u{646}".to_string(), vec![])], + "the feature is on exactly that letter" + ); + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none())); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')], "the neighbours keep their initial and final forms"); + // The pointer leaves the cell: a hovered cell is painted as hovered, not as the drawn one. + shapes(&ctx, &mut app, vec![egui::Event::PointerMoved(Pos2::ZERO)]); + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(highlighted(&found, &t), [squares(&found)[2].center()], "the applied variant is the one drawn"); + // The default glyph takes the feature off again, and undo takes each step back. + double_click(&ctx, &mut app, squares(&found)[0].center()); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().all(|r| r.style.features.is_empty()), "undo restores the features"); + } + + /// A variant no feature draws is still enabled: it replaces the glyph (`text.setGlyphAlternate`). + #[test] + fn a_variant_no_feature_draws_replaces_the_glyph() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 2, 2); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let cells = squares(&shapes(&ctx, &mut app, vec![])); + let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face"); + // The last cell is cv01's glyph c, which the style has no tag for. + double_click(&ctx, &mut app, cells[cells.len() - 1].center()); + assert_eq!(glyph_ids(&app, &id)[1], face.glyph_for('c')); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().any(|r| r.style.glyph_alternate.is_some())); + // A feature variant afterwards takes the choice away in the same step. + let found = shapes(&ctx, &mut app, vec![]); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, squares(&found)[2].center()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); + } + + /// A positional alternate without a supported feature still goes through the engine's glyph + /// choice validation, even though the isolated character has different alternates. + #[test] + fn a_positional_variant_no_feature_draws_replaces_the_glyph() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let gid = u32::from(source_sans_gid('d').expect("a glyph")); + assert!(face.alternates('\u{646}').is_empty(), "the isolated noon has no alternates"); + assert!(!face.is_unlisted_alternate('\u{646}', gid), "cv01 reaches the glyph"); + for view in ["Alternates for Current Selection", "Entire Font"] { + let (mut app, id) = editing("\u{628}\u{646}\u{62A}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + set_cell_index(&mut app, 0); + let plain = glyph_ids(&app, &id); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, view); + let at = if view == "Entire Font" { + entire_font_cell(&ctx, &mut app, &face, gid).0 + } else { + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 2, "the medial noon and its cv01 alternate"); + cells[1].center() + }; + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, if view == "Entire Font" { TALL } else { vec2(280.0, 420.0) }); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{view}: {}", app.ui.status); + assert_eq!(glyph_ids(&app, &id)[1], gid, "{view}"); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{646}\u{62A}", "the characters stay unchanged"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), plain, "{view}: undo restores the word"); + } + } + + /// Show › Entire Font also lists the glyphs no character maps. + #[test] + fn entire_font_lists_the_unencoded_glyphs() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let (mut app, id) = editing("\u{628}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let encoded = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let unencoded = face.unencoded_glyphs(); + assert!(unencoded.len() > 1000 && encoded == 4, "{} unencoded, {encoded} encoded", unencoded.len()); + let ctx = egui::Context::default(); + let all = texts(&ctx, &mut app); + assert!(all.iter().any(|s| s == &format!("{} glyphs", encoded + unencoded.len())), "{all:?}"); + assert!(face.glyph_name(unencoded[0]).is_some(), "the font names its glyphs"); + } + #[test] fn zoom_stops_at_the_ends() { assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); @@ -868,4 +1332,359 @@ mod tests { app.ui.glyphs_preview = 99; assert_eq!(cell_index(&app), CELL_DEFAULT); } + /// The panel tall enough to draw every row of the grid, so each glyph has a cell. + const TALL: egui::Vec2 = vec2(280.0, 8000.0); + + fn tall_shapes(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Each glyph preview with the colour it is drawn in, in the order of the grid. + fn inks(shapes: &[Shape]) -> Vec<(Rect, Color32)> { + fn walk(s: &Shape, out: &mut Vec<(Rect, Color32)>) { + match s { + Shape::Mesh(m) => { + let r = m.calc_bounds(); + if (r.width() - r.height()).abs() < 1.0 && r.width() >= 14.0 { + out.push((r, m.vertices.first().map_or(Color32::TRANSPARENT, |v| v.color))); + } + } + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, &mut out)); + out + } + + /// What a double-click on glyph `gid` of `face` would do to the selection ([`Fit::pick`], [`Fit::why`]). + fn verdict(ctx: &egui::Context, app: &VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> Result { + let mut out = Err(String::new()); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() }; + let mut frame = ctx.run_ui(raw, |ui| { + let fit = Fit::of(app, ui, face); + out = fit.pick(face, gid).ok_or_else(|| fit.why(face, gid)); + }); + frame.textures_delta.clear(); + out + } + + /// The cell of glyph `gid` in Show › Entire Font of `face`: the grid lists the characters, then + /// the glyphs no character maps. Its centre, and the colour its glyph is drawn in. + fn entire_font_cell(ctx: &egui::Context, app: &mut VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> (Pos2, Color32) { + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let unencoded = face.unencoded_glyphs(); + let cells = inks(&tall_shapes(ctx, app)); + assert_eq!(cells.len(), chars + unencoded.len(), "every glyph has a cell"); + let at = chars + unencoded.iter().position(|g| *g == gid).expect("an unencoded glyph"); + (cells[at].0.center(), cells[at].1) + } + + /// The owner's Kufi Murabba PIIIX Heavy (`~/Library/Fonts`), in the app's font database; `None` + /// when it is not installed. A file that is there but gives no face is a failure. + fn kufi_heavy() -> Option> { + let path = std::path::Path::new(&std::env::var_os("HOME")?).join("Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf"); + if !path.exists() { + eprintln!("skipped: {} is not installed", path.display()); + return None; + } + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display())); + FontDb::global().add_font(bytes); + let face = + FontDb::global().face("Kufi Murabba PIIIX", "Heavy").unwrap_or_else(|| panic!("{} exists but its face is missing", path.display())); + assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Heavy"), "the installed font, not a fallback"); + Some(face) + } + + fn gid_named(face: &vectorcraft_text::FontFace, name: &str) -> u32 { + (0..face.glyph_count()).find(|g| face.glyph_name(*g).as_deref() == Some(name)).unwrap_or_else(|| panic!("{name} missing")) + } + + /// The word al-Khwarizmi ending in `letter`, in Kufi Murabba PIIIX Heavy. + fn khwarizmi_in_heavy(letter: char) -> (VectorcraftApp, serde_json::Value, usize) { + let word = format!("\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}{letter}"); + let (mut app, id) = editing(&word); + app.run("text.setRangeStyle", json!({"id": id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + (app, id, word.len() - letter.len_utf8()) + } + + /// Applying each alternate of the final letter of a word in Kufi Murabba PIIIX Heavy, with the + /// letter selected as the Type tool selects it (the UTF-8 byte range of the character, the + /// last one of a right-to-left word), by a double-click on its cell in Alternates for Current + /// Selection and in Entire Font: the same glyph, one undo step, the rest of the word as it was. + /// Persian yeh, Arabic yeh and alef maksura each have their own `.fina.alt1` to `.fina.alt6`. + #[test] + fn a_final_letters_alternates_apply_from_alternates_and_from_entire_font() { + let Some(face) = kufi_heavy() else { return }; + for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] { + let (mut app, id, last) = khwarizmi_in_heavy(letter); + let plain = glyph_ids(&app, &id); + let ends = plain.len() - 1; + assert_eq!(plain[ends], gid_named(&face, &format!("{base}.fina")), "{base}: the final form"); + select(&mut app, last, last + letter.len_utf8()); + assert_eq!(selected_char(&app), Some(letter), "{base}: the selection is the last letter"); + let alternates: Vec = (1..=6).map(|n| gid_named(&face, &format!("{base}.fina.alt{n}"))).collect(); + let ctx = egui::Context::default(); + for view in ["Alternates for Current Selection", "Entire Font"] { + for (n, alt) in alternates.iter().enumerate() { + choose(&ctx, &mut app, view); + let undo = app.session.active().unwrap().history.undo.len(); + let at = if view == "Entire Font" { + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, *alt); + assert_eq!(ink, Tokens::get(&ctx).text_strong, "{base} {view}: {alt} is not dimmed for its own letter"); + at + } else { + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 7, "{base}: the final form and its six alternates"); + cells[n + 1].center() + }; + double_click_in(&ctx, &mut app, at, TALL); + let after = glyph_ids(&app, &id); + let mut want = plain.clone(); + want[ends] = *alt; + assert_eq!(after, want, "{base} {view} alt{}: only the last letter changes; status: {}", n + 1, app.ui.status); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{base} {view} alt{}: one undo step", n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), plain, "{base} {view}: undo"); + } + } + } + } + + /// A glyph of another letter is dimmed in Entire Font and a double-click changes nothing: the + /// tooltip's reason names the glyph's letter, where in a word it stands and the selected letter. + #[test] + fn a_glyph_that_cannot_go_on_the_selection_is_dimmed_and_says_why() { + let Some(face) = kufi_heavy() else { return }; + let glyph = gid_named(&face, "yehfarsi.fina.alt2"); + assert_eq!(glyph, 820, "the owner's glyph"); + for (letter, selected) in [('\u{064A}', "ي (U+064A)"), ('\u{0649}', "ى (U+0649)")] { + let (mut app, id, last) = khwarizmi_in_heavy(letter); + let plain = glyph_ids(&app, &id); + select(&mut app, last, last + letter.len_utf8()); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let why = verdict(&ctx, &app, &face, glyph).err().expect("not applicable"); + assert_eq!(why, format!("yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is {selected}.")); + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, t.text_disabled, "{letter}: dimmed"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id), plain, "{letter}: nothing changes"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + assert_eq!(app.ui.status, why, "the status says why"); + } + // The same glyph on Persian yeh applies. + let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}'); + select(&mut app, last, last + 2); + let ctx = egui::Context::default(); + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, Tokens::get(&ctx).text_strong); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id).last(), Some(&glyph)); + // Nothing selected, and a variant of the letter's other form. + select(&mut app, 0, 0); + assert_eq!(verdict(&ctx, &app, &face, glyph).err().as_deref(), Some("Select one character with the Type tool first.")); + let (_, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, Tokens::get(&ctx).text_disabled, "dimmed without a selection"); + select(&mut app, last, last + 2); + let medial = gid_named(&face, "yehfarsi.medi.alt1"); + assert_eq!( + verdict(&ctx, &app, &face, medial).err().as_deref(), + Some("yehfarsi.medi.alt1 is not an alternate of ی (U+06CC) as it stands in its word."), + "its own letter, another place in a word" + ); + } + + /// A glyph no feature reaches (`a.a`, `a.b` of a font with no feature for them) is applied by + /// Entire Font to the selected character as a glyph choice, not dimmed; one the font's naming + /// gives to another letter (`a.a` on `y`) stays dimmed, and so does every glyph with no + /// character selected. + #[test] + fn entire_font_applies_a_glyph_no_feature_reaches_to_its_letter() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face"); + let glyph = gid_named(&face, "a.a"); + assert!(face.feature_reaching(glyph).is_none() && face.glyph_origin(glyph).is_none(), "no feature reaches it"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let plain = glyph_ids(&app, &id); + // The cell of the glyph and its colour: this font's grid is longer than the panel is tall, + // but the glyph is in the first rows. + let cell_of = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("an unencoded glyph"); + let cells = inks(&tall_shapes(ctx, app)); + (cells[at].0.center(), cells[at].1) + }; + // Another letter, and nothing selected: dimmed, and a double-click changes nothing. + for (start, end) in [(2, 3), (0, 0)] { + select(&mut app, start, end); + assert!(verdict(&ctx, &app, &face, glyph).is_err()); + let (at, ink) = cell_of(&ctx, &mut app); + assert_eq!(ink, t.text_disabled, "{start}..{end}: dimmed"); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id), plain); + } + // The letter itself: enabled, one undo step, drawn with that glyph. + select(&mut app, 1, 2); + assert!(matches!(verdict(&ctx, &app, &face, glyph), Ok(Pick::Glyph(g)) if g == glyph)); + let (at, ink) = cell_of(&ctx, &mut app); + assert_eq!(ink, t.text_strong); + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + assert_eq!(glyph_ids(&app, &id), [plain[0], glyph, plain[2]]); + } + + /// A presentation form picked in Entire Font (beh medial, U+FE92) with the caret before the last + /// letter would be a fixed shape that does not join the word: the letter (beh) is inserted, so + /// the word is shaped as if it were typed, and the status says so. + #[test] + fn a_presentation_form_inserts_its_letter_so_the_word_joins() { + let Some(face) = kufi_heavy() else { return }; + let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}'); + let typed = { + let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{628}\u{6CC}"; + let (mut other, other_id) = editing(word); + other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + glyph_ids(&other, &other_id) + }; + let gid_of = |c| face.chars().into_iter().find(|(ch, _)| *ch == c).expect("the character is in the font").1; + // Typed as the presentation form itself, the same word is shaped differently: it does not join. + let literal = { + let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{FE92}\u{6CC}"; + let (mut other, other_id) = editing(word); + other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + glyph_ids(&other, &other_id) + }; + eprintln!("typed with beh {typed:?}\ntyped with U+FE92 {literal:?}"); + assert_ne!(literal, typed, "the presentation form is not shaped like the letter"); + assert_eq!(base_letter(&face, '\u{FE92}', gid_of('\u{FE92}')), Some('\u{0628}'), "medial beh is a form of beh"); + assert_eq!(base_letter(&face, '\u{0628}', gid_of('\u{0628}')), None, "beh itself inserts as it is"); + let tip = encoded_tip(&face, '\u{FE92}', gid_of('\u{FE92}')); + assert!(tip.contains("(U+0628)"), "{tip}"); + select(&mut app, last, last); + let ctx = egui::Context::default(); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + let cells = inks(&tall_shapes(&ctx, &mut app)); + let at = cells[chars.iter().position(|(c, _)| *c == '\u{FE92}').expect("the presentation form is in the font")].0.center(); + double_click_in(&ctx, &mut app, at, TALL); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.plain_text().ends_with("\u{645}\u{628}\u{6CC}"), "the letter is in the text: {}", text.plain_text()); + assert_eq!(glyph_ids(&app, &id), typed, "shaped as the word typed with beh"); + assert!(app.ui.status.starts_with("Inserted ب (U+0628)"), "{}", app.ui.status); + } + + #[test] + fn an_unlisted_choice_without_gsub_can_be_reset_from_the_default_cell() { + use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font}; + let db = FontDb::global(); + db.add_font(unlisted_variable_font().expect("the test font builds")); + let face = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face"); + let glyph = gid_named(&face, "a.a"); + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY})).unwrap(); + select(&mut app, 0, 1); + let plain = glyph_ids(&app, &id); + app.run("text.setGlyphAlternate", json!({"id": id, "start": 0, "gid": glyph})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [glyph]); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 1, "the default remains available without any GSUB alternates"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, cells[0].center()); + assert_eq!(glyph_ids(&app, &id), plain); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [glyph]); + } + + #[test] + fn entire_font_applies_an_unlisted_glyph_at_the_selections_variable_axes() { + use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font}; + let db = FontDb::global(); + db.add_font(unlisted_variable_font().expect("the test font builds")); + let base = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face"); + let glyph = gid_named(&base, "a.a"); + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY, "fontAxes": {"wght": 650}})).unwrap(); + select(&mut app, 0, 1); + let target = selected_target(&app).expect("the selected character"); + let face = db.style_face(target.style).expect("the variable instance"); + assert_ne!(face.id(), base.id(), "a non-default setting has its own face id"); + let ctx = egui::Context::default(); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("the unencoded glyph"); + let cells = inks(&tall_shapes(&ctx, &mut app)); + assert_eq!(cells[at].1, Tokens::get(&ctx).text_strong, "the instance's alternate is enabled"); + double_click_in(&ctx, &mut app, cells[at].0.center(), TALL); + assert_eq!(glyph_ids(&app, &id), [glyph]); + assert_eq!(selected_target(&app).unwrap().style.font_axes["wght"], 650.0); + app.run("edit.undo", json!({})).unwrap(); + assert_ne!(glyph_ids(&app, &id), [glyph]); + } + + #[test] + fn encoded_characters_normalize_only_arabic_presentation_forms() { + use vectorcraft_text::test_fonts::{ALIASED_JOINING_FAMILY, aliased_joining_font}; + let db = FontDb::global(); + db.add_font(aliased_joining_font().expect("the test font builds")); + let face = db.face(ALIASED_JOINING_FAMILY, "Regular").expect("the test face"); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + for (c, expected) in [('B', "B"), ('\u{FE91}', "\u{0628}")] { + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": ALIASED_JOINING_FAMILY})).unwrap(); + select(&mut app, 0, 1); + let at = chars.iter().position(|(ch, _)| *ch == c).expect("an encoded character"); + let origin = face.glyph_origin(chars[at].1).expect("the shared positional glyph"); + assert_eq!(origin.ch, '\u{0628}'); + assert!(origin.position().is_some()); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gl-font", Some((ALIASED_JOINING_FAMILY.to_string(), "Regular".to_string()))); + let cells = inks(&tall_shapes(&ctx, &mut app)); + double_click_in(&ctx, &mut app, cells[at].0.center(), TALL); + let state = app.session.active().unwrap(); + let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), expected, "the encoded {c} cell inserts the intended character"); + app.run("edit.undo", json!({})).unwrap(); + let state = app.session.active().unwrap(); + let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "a"); + } + } + + #[test] + fn a_normalized_glyph_without_a_text_target_preserves_the_insertion_error() { + use vectorcraft_text::test_fonts::{ALIASED_JOINING_FAMILY, aliased_joining_font}; + let db = FontDb::global(); + db.add_font(aliased_joining_font().expect("the test font builds")); + let face = db.face(ALIASED_JOINING_FAMILY, "Regular").expect("the test face"); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let undo = app.session.active().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gl-font", Some((ALIASED_JOINING_FAMILY.to_string(), "Regular".to_string()))); + let cells = inks(&tall_shapes(&ctx, &mut app)); + let at = |c| chars.iter().position(|(ch, _)| *ch == c).expect("the encoded glyph"); + double_click_in(&ctx, &mut app, cells[at('B')].0.center(), TALL); + let failed = app.ui.status.clone(); + assert!(!failed.is_empty(), "the ordinary character reports the missing text target"); + double_click_in(&ctx, &mut app, cells[at('\u{FE91}')].0.center(), TALL); + assert_eq!(app.ui.status, failed, "normalization must not overwrite the insertion failure with a success"); + let state = app.session.active().unwrap(); + assert_eq!(state.history.undo.len(), undo); + let mut texts = 0; + state.doc.walk(|n| texts += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(texts, 0, "neither glyph created or changed text"); + } } diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 500649b7ed..83f003e150 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -28,9 +28,9 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_color::gradient::{ - MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, + MAX_REPEATS, MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, }; -use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_doc::{NodeId, StrokeGradientMode}; use super::{active_paint, live_run, pstate, set_pstate}; @@ -315,6 +315,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; } method_row(app, ui, &g, is_grad); + spread_row(app, ui, &g, is_grad); // The proxy beside the angle and aspect ratio fields. ui.horizontal(|ui| { super::proxy(app, ui, 36.0); @@ -397,6 +398,39 @@ fn method_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: }); } +/// The Spread row: what a linear, radial, conic or diamond gradient paints past the ends of its +/// stops (Pad, Repeat or Reflect), and how many times Repeat and Reflect run the stops along the +/// vector (1 to [`MAX_REPEATS`]; dimmed with Pad). +fn spread_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { + let spreads = [(GradientSpread::Pad, tl!("Pad")), (GradientSpread::Repeat, tl!("Repeat")), (GradientSpread::Reflect, tl!("Reflect"))]; + let current = g.gradient.spread; + let shown = spreads.iter().find(|(s, _)| *s == current).map_or("", |(_, l)| *l); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Spread:")); + let labels = spreads.map(|(_, l)| l); + let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-spread", shown, &labels, 80.0)).inner; + if let Some((s, _)) = chosen.and_then(|i| spreads.get(i)) + && *s != current + { + edit(app, json!({"spread": s.label().to_lowercase()}), Live::Released); + } + let on = is_grad && !current.is_pad(); + let n = f64::from(g.gradient.repeat_count()); + let set = ui + .add_enabled_ui(on, |ui| widgets::spin_plain_named(ui, "grad-repeats", Some(tl!("Repeats")), n, "", 0, 56.0, 1.0, 1.0, &REPEAT_PRESETS)) + .inner; + if let Some(r) = set { + let r = r.round().clamp(1.0, f64::from(MAX_REPEATS)); + if r != n { + edit(app, json!({"repeats": r}), Live::Released); + } + } + }); +} + +/// The Repeats field's menu. +const REPEAT_PRESETS: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; + /// The Presets row: a menu of the preset gradients by library (theme), then the /// saved ones; a choice applies to the gradient behind the active proxy, keeping its placement /// (`gradient.presets.apply`; a solid or no paint becomes the gradient). The menu shows the preset diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 27b0eda0b4..589dbc523a 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -328,6 +328,44 @@ fn the_method_menu_makes_the_gradient_perceptual() { 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] +fn the_spread_menu_and_repeats_field_set_the_spread() { + use vectorcraft_color::GradientSpread; + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "Spread:"), "{texts:?}"); + let shown = texts.iter().find(|(t, _)| t == "Pad").expect("Pad shown").1; + let undo = app.session.doc().unwrap().history.undo.len(); + let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }; + // While it pads, the Repeats field takes no count. + let field = p.widget_after(shown); + p.click(&mut app, field.center()); + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("4".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert_eq!((fill(&app).gradient.repeats, app.session.doc().unwrap().history.undo.len()), (1, undo), "dimmed with Pad"); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Pad").expect("Pad shown").1; + let texts = p.click(&mut app, shown); + let option = texts.iter().find(|(t, _)| t == "Reflect").expect("the menu lists Reflect").1; + p.click(&mut app, option); + assert_eq!(fill(&app).gradient.spread, GradientSpread::Reflect); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The count: typed into the field beside the menu. + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Reflect").expect("the menu shows Reflect").1; + let field = p.widget_after(shown); + p.click(&mut app, field.center()); + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("4".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert_eq!(fill(&app).gradient.repeat_count(), 4); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(fill(&app).gradient.spread, GradientSpread::Pad); +} + #[test] fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]); diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index e49832c0da..cce131c7a3 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -2,10 +2,12 @@ //! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started //! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each //! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a -//! tutorial previews its numbered steps. Back goes up one level. `learn.list` lists the tutorials, -//! `learn.show` opens one and `window.learn` shows or hides the panel. +//! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool +//! is ([`locate`]). Back goes up one level. `learn.list` lists the tutorials, `learn.show` opens one, +//! `learn.locateTool` shows where a tool is and `window.learn` shows or hides the panel. pub mod catalog; +pub mod locate; use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -86,7 +88,7 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.learn.skill = Some(skill.id); app.ui.learn.tutorial = None; } - steps(ui, tutorial); + steps(app, ui, tutorial); return; } if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { @@ -113,18 +115,52 @@ fn back(ui: &mut Ui) -> bool { crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() } -/// A tutorial's title and its numbered steps. -fn steps(ui: &mut Ui, tutorial: &catalog::Tutorial) { +/// A tutorial's title and its numbered steps. The tool's name in a step is a blue link: a click, or +/// Enter or Space while it has the keyboard focus, shows where the tool is (`learn.locateTool`). +fn steps(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &catalog::Tutorial) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong)); ui.add_space(4.0); + let mut locate = None; for (i, step) in tutorial.steps.iter().enumerate() { ui.horizontal_top(|ui| { ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim)); - ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + let (Some(tool), Some((before, name, after))) = (step.tool, step.tool_name()) else { + ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + return; + }; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if !before.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(before).color(t.text)).wrap()); + } + if tool_link(ui, &t, name).clicked() { + locate = Some(tool); + } + if !after.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(after).color(t.text)).wrap()); + } + }); }); ui.add_space(2.0); } + if let Some(tool) = locate { + if let Err(e) = app.run("learn.locateTool", json!({ "tool": tool })) { + app.status(e); + } + ui.ctx().request_repaint(); + } +} + +/// A tool's name in a step, drawn as a link: blue and underlined, with an outline while it has the +/// keyboard focus. +fn tool_link(ui: &mut Ui, t: &Tokens, name: &str) -> egui::Response { + let resp = ui.add(egui::Label::new(egui::RichText::new(name).color(t.selection).underline()).sense(Sense::click())); + if resp.has_focus() { + ui.painter().rect_stroke(resp.rect.expand(2.0), egui::CornerRadius::same(2), Stroke::new(1.0, t.selection), egui::StrokeKind::Outside); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, ui.is_enabled(), name)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tl!("Show where this tool is")) } /// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the @@ -155,12 +191,13 @@ fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The Learn panel's UI commands: `window.learn`, `learn.list` and `learn.show`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show` and `learn.locateTool`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { Some(match id { "window.learn" => toggle(app, p), "learn.list" => Ok(list()), "learn.show" => open(app, p), + "learn.locateTool" => locate::locate_tool(app, p), _ => return None, }) } @@ -206,7 +243,7 @@ fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; crate::panel_docking::reveal_panel(app, ID)?; app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; - let steps: Vec<&str> = tutorial.steps.iter().map(|s| s.text).collect(); + let steps: Vec = tutorial.steps.iter().map(catalog::Step::plain).collect(); Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) } diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs index b1ffd897d7..98f0d6f355 100644 --- a/crates/ui-egui/src/panels/learn/catalog.rs +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -3,6 +3,8 @@ /// One step of a tutorial: what to do, and the tool it chooses or the command it runs, if any. pub struct Step { + /// The step as the panel shows it. In a step that names a tool, the tool's name is in square + /// brackets, and the panel draws it as a link that shows where the tool is. pub text: &'static str, /// A tool id ([`vectorcraft_tools::catalog::tool_info`]) the step names. pub tool: Option<&'static str>, @@ -10,6 +12,22 @@ pub struct Step { pub command: Option<&'static str>, } +impl Step { + /// The text before the tool's name, the name, and the text after it; none for a step that + /// names no tool or has no bracketed name. + pub fn tool_name(&self) -> Option<(&'static str, &'static str, &'static str)> { + self.tool?; + let (before, rest) = self.text.split_once('[')?; + let (name, after) = rest.split_once(']')?; + Some((before, name, after)) + } + + /// The text as it reads, without the brackets around the tool's name. + pub fn plain(&self) -> String { + self.text.replace(['[', ']'], "") + } +} + /// A tutorial: an id for `learn.show`, its title and its numbered steps. pub struct Tutorial { pub id: &'static str, @@ -29,7 +47,7 @@ const fn say(text: &'static str) -> Step { Step { text, tool: None, command: None } } -/// A step that uses tool `id`. +/// A step that uses tool `id`, whose name `text` holds in square brackets. const fn tool(text: &'static str, id: &'static str) -> Step { Step { text, tool: Some(id), command: None } } @@ -49,7 +67,7 @@ pub const SKILLS: &[Skill] = &[ id: "draw-and-color", title: "Draw and color a shape", steps: &[ - tool("Choose the Rectangle tool in the toolbar.", "rectangle"), + tool("Choose the [Rectangle tool] in the toolbar.", "rectangle"), say("Drag on the artboard to draw a rectangle."), say("With the rectangle still selected, click a color in the Swatches panel to fill it."), command("Click the Stroke box under the tools, then click another swatch to color the outline.", "paint.toggleActive"), @@ -59,16 +77,16 @@ pub const SKILLS: &[Skill] = &[ id: "combine-shapes", title: "Combine shapes into one", steps: &[ - tool("Choose the Ellipse tool and draw two circles that overlap.", "ellipse"), - tool("Choose the Selection tool and drag across both circles to select them.", "selection"), - tool("Choose the Shape Builder tool and drag across the parts you want to merge.", "shapeBuilder"), + tool("Choose the [Ellipse tool] and draw two circles that overlap.", "ellipse"), + tool("Choose the [Selection tool] and drag across both circles to select them.", "selection"), + tool("Choose the [Shape Builder tool] and drag across the parts you want to merge.", "shapeBuilder"), ], }, Tutorial { id: "add-headline", title: "Add a headline", steps: &[ - tool("Choose the Type tool and click on the artboard.", "type"), + tool("Choose the [Type tool] and click on the artboard.", "type"), say("Type a few words, then press Escape to finish."), say("Pick a font and a size in the Character panel."), ], @@ -83,7 +101,7 @@ pub const SKILLS: &[Skill] = &[ id: "pen-paths", title: "Draw paths with the Pen", steps: &[ - tool("Choose the Pen tool.", "pen"), + tool("Choose the [Pen tool].", "pen"), say("Click to place corner points, or drag to pull out a curve."), say("Click the first point again to close the shape, or press Enter to leave it open."), ], @@ -92,18 +110,18 @@ pub const SKILLS: &[Skill] = &[ id: "smooth-curves", title: "Draw smooth curves", steps: &[ - tool("Choose the Curvature tool.", "curvature"), + tool("Choose the [Curvature tool].", "curvature"), say("Click to place points: the path bends smoothly through each one."), - tool("Choose the Direct Selection tool and drag a point to reshape the curve.", "directSelection"), + tool("Choose the [Direct Selection tool] and drag a point to reshape the curve.", "directSelection"), ], }, Tutorial { id: "pencil-sketch", title: "Sketch with the Pencil", steps: &[ - tool("Choose the Pencil tool.", "pencil"), + tool("Choose the [Pencil tool].", "pencil"), say("Drag to draw a freehand line."), - tool("Choose the Smooth tool and drag along the line to even it out.", "smooth"), + tool("Choose the [Smooth tool] and drag along the line to even it out.", "smooth"), ], }, ], @@ -116,7 +134,7 @@ pub const SKILLS: &[Skill] = &[ id: "fill-and-stroke", title: "Fill and stroke", steps: &[ - tool("Select an object with the Selection tool.", "selection"), + tool("Select an object with the [Selection tool].", "selection"), say("Click the Fill box under the tools, then pick a color in the Color panel."), command("Click the double arrow beside the boxes to swap the fill and the stroke.", "paint.swap"), ], @@ -125,8 +143,8 @@ pub const SKILLS: &[Skill] = &[ id: "gradient", title: "Add a gradient", steps: &[ - tool("Select an object with the Selection tool.", "selection"), - tool("Choose the Gradient tool and click the object to give it a gradient.", "gradient"), + tool("Select an object with the [Selection tool].", "selection"), + tool("Choose the [Gradient tool] and click the object to give it a gradient.", "gradient"), say("Drag across the object to set the gradient's direction and length."), ], }, @@ -134,8 +152,8 @@ pub const SKILLS: &[Skill] = &[ id: "copy-colors", title: "Copy colors from other art", steps: &[ - tool("Select the object to color with the Selection tool.", "selection"), - tool("Choose the Eyedropper tool and click the art whose colors you want.", "eyedropper"), + tool("Select the object to color with the [Selection tool].", "selection"), + tool("Choose the [Eyedropper tool] and click the art whose colors you want.", "eyedropper"), ], }, ], @@ -148,8 +166,8 @@ pub const SKILLS: &[Skill] = &[ id: "area-type", title: "Flow text into a shape", steps: &[ - tool("Draw a rectangle for the text to fill.", "rectangle"), - tool("Choose the Area Type tool and click the rectangle's edge.", "areaType"), + tool("Draw a rectangle for the text to fill with the [Rectangle tool].", "rectangle"), + tool("Choose the [Area Type tool] and click the rectangle's edge.", "areaType"), say("Type or paste your text: it wraps inside the shape."), ], }, @@ -157,8 +175,8 @@ pub const SKILLS: &[Skill] = &[ id: "type-on-path", title: "Type along a path", steps: &[ - tool("Draw a curved path with the Pen tool.", "pen"), - tool("Choose the Type on a Path tool and click the path.", "typeOnPath"), + tool("Draw a curved path with the [Pen tool].", "pen"), + tool("Choose the [Type on a Path tool] and click the path.", "typeOnPath"), say("Type your text: it follows the path."), ], }, @@ -166,7 +184,7 @@ pub const SKILLS: &[Skill] = &[ id: "outline-type", title: "Turn type into shapes", steps: &[ - tool("Select a text object with the Selection tool.", "selection"), + tool("Select a text object with the [Selection tool].", "selection"), command("Choose Type › Create Outlines to turn its letters into paths.", "type.createOutlines"), ], }, diff --git a/crates/ui-egui/src/panels/learn/locate.rs b/crates/ui-egui/src/panels/learn/locate.rs new file mode 100644 index 0000000000..607da1c45c --- /dev/null +++ b/crates/ui-egui/src/panels/learn/locate.rs @@ -0,0 +1,206 @@ +//! `learn.locateTool`, which the link on a tool's name in a tutorial step runs: it marks the tool +//! where the current layout shows it, without choosing it. A tool shown on its toolbar button has +//! the button marked; a tool in a collapsed group opens the group's flyout with the tool's row +//! marked; a group torn off into a strip is raised with the tool marked in it; a tool on no shown +//! toolbar is marked in the All Tools drawer. The mark lasts [`LIFETIME`] seconds after it first +//! shows, until Escape, or until the next locate replaces it. + +use egui::{Context, CornerRadius, LayerId, Rect, Stroke, StrokeKind}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::state::UiState; +use crate::theme::Tokens; + +/// Seconds the mark stays on screen after it first shows. +pub const LIFETIME: f64 = 3.0; +/// The width of the mark's outline. +pub const MARK_WIDTH: f32 = 2.0; +/// Passes a locate waits for its mark to draw before it is dropped: the toolbar no longer shows +/// the tool, or the flyout it opened has closed. +const UNSEEN: u8 = 3; + +/// Which control shows the located tool. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Target { + /// The Tools panel's toolbar: a button, a group's flyout or a strip torn off it. + Tools, + /// The toolbar made with New Toolbar called this: a button, a group's flyout or a strip. + Custom(String), + /// The All Tools drawer beside the Tools panel. + AllTools, +} + +/// A tool being located and how far its mark has got. +#[derive(Clone, Debug)] +pub struct Locate { + pub tool: &'static str, + pub target: Target, + /// When the mark first drew (the context's time). + since: Option, + /// The target was scrolled into view. + pub(crate) scrolled: bool, + /// The group's flyout was opened or its strip raised. + pub(crate) revealed: bool, + /// The toolbar made with New Toolbar that holds the tool was brought to the front. + raised: bool, + /// The mark drew in this pass. + drawn: bool, + /// Passes in a row in which the mark did not draw. + unseen: u8, +} + +/// The located tool when the Tools panel's toolbar (`toolbar` none) or the toolbar made with New +/// Toolbar called `toolbar` shows it. +pub(crate) fn wanted(ui: &UiState, toolbar: Option<&str>) -> Option<&'static str> { + let l = ui.learn_locate.as_ref()?; + let here = match (&l.target, toolbar) { + (Target::Tools, None) => true, + (Target::Custom(name), Some(t)) => name == t, + _ => false, + }; + here.then_some(l.tool) +} + +/// The located tool when the All Tools drawer beside the Tools panel shows it. +pub(crate) fn wanted_in_drawer(ui: &UiState) -> Option<&'static str> { + let l = ui.learn_locate.as_ref()?; + (l.target == Target::AllTools && ui.tools_drawer && ui.drawer_toolbar.is_none()).then_some(l.tool) +} + +/// Whether the target is still to be scrolled into view; true once. +pub(crate) fn scroll_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.scrolled, true)) +} + +/// Whether the target's flyout is still to be opened or its strip raised; true once. +pub(crate) fn reveal_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.revealed, true)) +} + +/// Whether the toolbar made with New Toolbar that holds the tool is still to be brought to the +/// front; true once. +pub(crate) fn raise_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.raised, true)) +} + +/// Whether a locate is under way and its mark has not drawn yet. +pub(crate) fn unmarked(ui: &UiState) -> bool { + ui.learn_locate.as_ref().is_some_and(|l| l.since.is_none()) +} + +/// Mark `rect`, the located tool's control as drawn this pass on `layer`, clipped to `clip` and the +/// window. The first mark starts the [`LIFETIME`]; once it has passed, the locate ends. +pub(crate) fn mark(app: &mut VectorcraftApp, ctx: &Context, layer: LayerId, clip: Rect, rect: Rect) { + let Some(l) = app.ui.learn_locate.as_mut() else { return }; + let now = ctx.input(|i| i.time); + let since = *l.since.get_or_insert(now); + let left = LIFETIME - (now - since); + if left <= 0.0 { + app.ui.learn_locate = None; + return; + } + l.drawn = true; + ctx.request_repaint_after_secs(left as f32); + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(layer).with_clip_rect(clip.intersect(ctx.content_rect())); + painter.rect_stroke(rect.shrink(1.0), CornerRadius::same(6), Stroke::new(MARK_WIDTH, t.selection), StrokeKind::Inside); +} + +/// After the toolbars and panels have drawn: Escape ends the locate, also an Escape that closed the +/// All Tools drawer or ended something else first, and so does a mark that has not drawn for +/// [`UNSEEN`] passes. +pub(crate) fn tidy(app: &mut VectorcraftApp, ctx: &Context) { + let Some(l) = app.ui.learn_locate.as_mut() else { return }; + // The raw events keep a key that a handler consumed this pass. + let escape = ctx.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }))); + if escape { + app.ui.learn_locate = None; + return; + } + if std::mem::take(&mut l.drawn) { + l.unseen = 0; + } else { + l.unseen += 1; + if l.unseen > UNSEEN { + app.ui.learn_locate = None; + } + } +} + +/// The reply for a tool that no toolbar can show now, with what would show it. +fn unavailable(tool: &str, reason: &str) -> Value { + json!({"tool": tool, "location": "unavailable", "toolbar": null, "reason": reason}) +} + +/// Mark `tool` on `target` from the next pass, replacing an earlier locate; the flyout or drawer an +/// earlier one opened closes. +fn start(app: &mut VectorcraftApp, tool: &'static str, target: Target) { + if let Some(old) = app.ui.learn_locate.take() { + app.ui.flyout = None; + if old.target == Target::AllTools && target != Target::AllTools && app.ui.drawer_toolbar.is_none() { + app.ui.tools_drawer = false; + } + } + app.ui.learn_locate = Some(Locate { tool, target, since: None, scrolled: false, revealed: false, raised: false, drawn: false, unseen: 0 }); +} + +/// `learn.locateTool {tool}`: mark where `tool` is in the current layout → +/// `{tool, location, toolbar}`, where `location` is `toolbar` (its button), `flyout` (a row of its +/// collapsed group's flyout, which opens), `strip` (its group torn off into a strip, which comes +/// to the front), `allTools` (no shown toolbar holds it: the All Tools drawer opens) or +/// `unavailable` (with `reason`), and `toolbar` names the toolbar made with New Toolbar that +/// holds it (null for the Tools panel's). A Tools panel that Window › Tools hid shows again; the +/// tool is not chosen, and the remembered tool of its group stays. A missing, non-string or unknown +/// id is an error and changes nothing. +pub(crate) fn locate_tool(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = "tool"; + let tool = match p.get(key) { + None | Some(Value::Null) => return Err("tool (a tool id) is required".into()), + Some(Value::String(tool)) => tool.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let tool = vectorcraft_tools::tool_info(tool).ok_or_else(|| format!("unknown tool `{tool}`"))?.id; + if app.ui.screen_mode >= 3 { + return Ok(unavailable(tool, "Presentation Mode hides the toolbars: change the screen mode to show them")); + } + // Tab hides the toolbars made with New Toolbar until the next Tab, also once Window › Tools + // shows the Tools panel again. + let tab_hid = app.ui.tab_hid.is_some(); + if tab_hid && !app.ui.toolbar { + return Ok(unavailable(tool, "Tab has hidden the toolbars: press Tab to show them")); + } + if let Some(slot) = crate::toolbar::slots(app).into_iter().map(|(_, s)| s).find(|s| s.contains(&tool)) { + let location = if slot.len() > 1 && crate::toolbar::is_floating(app, None, slot[0]) { + "strip" + } else if crate::toolbar::shown_tool(app, &slot) == tool { + "toolbar" + } else { + "flyout" + }; + app.ui.toolbar = true; + start(app, tool, Target::Tools); + return Ok(json!({"tool": tool, "location": location, "toolbar": null})); + } + let active = app.session.tool_id(); + let custom = app.ui.custom_toolbars.iter().filter(|b| b.shown && !tab_hid).find_map(|bar| { + let slot = bar.slots.iter().find(|s| s.iter().any(|t| t == tool))?; + let tools: Vec<&'static str> = slot.iter().filter_map(|id| vectorcraft_tools::tool_info(id).map(|t| t.id)).collect(); + let first = *tools.first()?; + let location = if tools.len() > 1 && crate::toolbar::is_floating(app, Some(&bar.name), first) { + "strip" + } else if crate::custom_toolbars::shown_tool(bar, active, &tools) == Some(tool) { + "toolbar" + } else { + "flyout" + }; + Some((bar.name.clone(), location)) + }); + if let Some((name, location)) = custom { + start(app, tool, Target::Custom(name.clone())); + return Ok(json!({"tool": tool, "location": location, "toolbar": name})); + } + app.run("window.editToolbar", json!({"open": true}))?; + start(app, tool, Target::AllTools); + Ok(json!({"tool": tool, "location": "allTools", "toolbar": null})) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index d4dbf8f00b..7014785eda 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -72,8 +72,20 @@ fn the_catalog_is_valid() { assert!(!tutorial.title.is_empty() && !tutorial.steps.is_empty(), "tutorial {} is empty", tutorial.id); for step in tutorial.steps { assert!(step.text.ends_with('.'), "{}: {:?}", tutorial.id, step.text); + let brackets = step.text.matches(['[', ']']).count(); if let Some(tool) = step.tool { - assert!(vectorcraft_tools::catalog::tool_info(tool).is_some(), "{}: no tool {tool}", tutorial.id); + let info = vectorcraft_tools::catalog::tool_info(tool); + assert!(info.is_some(), "{}: no tool {tool}", tutorial.id); + // The step's link names the tool it locates, once. + let name = step.tool_name().map(|(_, name, _)| name); + assert!( + brackets == 2 && name.zip(info).is_some_and(|(n, t)| n.eq_ignore_ascii_case(t.label)), + "{}: {:?}", + tutorial.id, + step.text + ); + } else { + assert_eq!(brackets, 0, "{}: {:?} links no tool", tutorial.id, step.text); } if let Some(cmd) = step.command { let known = @@ -189,7 +201,9 @@ fn a_tutorial_previews_its_steps() { assert_eq!(app.ui.learn.tutorial, Some(tutorial.id)); let text = painted_text(&mut app, show); for (i, step) in tutorial.steps.iter().enumerate() { - assert!(text.contains(&format!("{}.", i + 1)) && text.contains(step.text), "{} in {text}", step.text); + let parts = step.tool_name().map_or(vec![step.text], |(before, name, after)| vec![before, name, after]); + assert!(text.contains(&format!("{}.", i + 1)) && parts.iter().all(|p| text.contains(p)), "{} in {text}", step.text); + assert!(!text.contains('['), "no brackets: {text}"); } click(&mut app, &ctx, "Back", show); assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); @@ -229,3 +243,92 @@ fn learn_show_rejects_a_missing_or_unknown_tutorial() { assert!(!shown(&app)); } } + +/// The Pen tutorial's steps, as the panel shows them. +fn pen_paths(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + app.ui.learn = View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("pen-paths") }; + show(app, ui); +} + +/// A step's tool name is a link; a click on it locates that tool without choosing it, and +/// `learn.show` gives the step's text without the link's brackets. +#[test] +fn a_tool_name_in_a_step_is_a_link_that_locates_the_tool() { + let mut app = app(); + let ctx = context(); + let nodes = frame(&mut app, &ctx, vec![], pen_paths); + let link = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Pen tool")).expect("the Pen tool link"); + assert_eq!(link.role(), egui::accesskit::Role::Link); + assert_eq!(nodes.iter().filter(|n| n.role() == egui::accesskit::Role::Link).count(), 1, "only the step that names a tool has a link"); + click(&mut app, &ctx, "Pen tool", pen_paths); + let located = app.ui.learn_locate.as_ref().map(|l| (l.tool, l.target.clone())); + assert_eq!(located, Some(("pen", super::locate::Target::Tools)), "{}", app.ui.status); + assert_eq!(app.session.tool_id(), "selection", "the link does not choose the tool"); + let shown_tutorial = app.run("learn.show", json!({"tutorial": "pen-paths"})).unwrap(); + assert_eq!(shown_tutorial["steps"][0], "Choose the Pen tool."); +} + +/// Enter on the link, once Tab has moved the keyboard focus to it, locates the tool. +#[test] +fn enter_on_a_focused_tool_link_locates_the_tool() { + let mut app = app(); + let ctx = context(); + let link = frame(&mut app, &ctx, vec![], pen_paths) + .into_iter() + .find(|n| n.label().is_some_and(|l| l == "Pen tool")) + .and_then(|n| n.bounds()) + .expect("the Pen tool link"); + let key = |key, pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let focused_on_link = |ctx: &egui::Context| { + ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| { + let c = r.rect.center(); + ((link.x0 + link.x1) / 2.0 - f64::from(c.x)).abs() < 1.0 && ((link.y0 + link.y1) / 2.0 - f64::from(c.y)).abs() < 1.0 + }) + }; + let mut found = false; + for _ in 0..30 { + frame(&mut app, &ctx, vec![key(egui::Key::Tab, true), key(egui::Key::Tab, false)], pen_paths); + if focused_on_link(&ctx) { + found = true; + break; + } + } + assert!(found, "Tab reaches the link"); + assert!(app.ui.learn_locate.is_none()); + frame(&mut app, &ctx, vec![key(egui::Key::Enter, true), key(egui::Key::Enter, false)], pen_paths); + frame(&mut app, &ctx, vec![], pen_paths); + assert_eq!(app.ui.learn_locate.as_ref().map(|l| l.tool), Some("pen")); +} + +/// A missing, mistyped or unknown tool id is an error and changes nothing. +#[test] +fn learn_locate_tool_rejects_a_missing_or_unknown_tool() { + let mut app = app(); + app.ui.toolbar = false; + for (params, error) in [ + (json!({}), "tool (a tool id) is required"), + (json!({"tool": null}), "tool (a tool id) is required"), + (json!({"tool": 3}), "`tool` must be a string"), + (json!({"tool": "nope"}), "unknown tool `nope`"), + ] { + assert_eq!(app.run("learn.locateTool", params).unwrap_err(), error); + assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && !app.ui.tools_drawer && app.ui.flyout.is_none()); + } +} + +/// With the toolbars hidden by Presentation Mode or by Tab, nothing can be marked: the reply says +/// so and the screen stays as it is. +#[test] +fn learn_locate_tool_is_unavailable_while_the_toolbars_are_hidden() { + let mut app = app(); + app.ui.screen_mode = 3; + let reply = app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + assert_eq!((reply["location"].as_str(), reply["reason"].is_string()), (Some("unavailable"), true), "{reply}"); + assert!(app.ui.learn_locate.is_none()); + app.ui.screen_mode = 0; + app.run("window.dock", json!({})).unwrap(); + assert!(app.ui.tab_hid.is_some() && !app.ui.toolbar); + let reply = app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + assert_eq!(reply["location"], "unavailable", "{reply}"); + assert!(app.ui.learn_locate.is_none() && !app.ui.toolbar && app.ui.tab_hid.is_some(), "Tab's state stays"); +} diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index da8048407b..60eb4c18d0 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -249,11 +249,12 @@ pub fn thumb_to_doc(region: DRect, thumb: Rect, p: egui::Pos2) -> Point { Point::new(region.x0 + (p.x as f64 - ox) / s, region.y0 + (p.y as f64 - oy) / s) } -/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v`, mapped -/// into the document (turned with the view) and then into the thumbnail, cut to the thumbnail -/// grown by a point. Fewer than three points when the view is outside the thumbnail. -fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View) -> Vec { - let xf = crate::canvas::Xf::new(canvas, v); +/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v` (with the +/// interface zoomed by `ui_zoom`), mapped into the document (turned with the view) and then into +/// the thumbnail, cut to the thumbnail grown by a point. Fewer than three points when the view is +/// outside the thumbnail. +fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View, ui_zoom: f32) -> Vec { + let xf = crate::canvas::Xf::at(canvas, v, ui_zoom); let mut pts: Vec = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()] .iter() .map(|c| doc_to_thumb(region, thumb, xf.to_doc(*c))) @@ -330,7 +331,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The view box: the canvas's corners in the document, turned with the view, cut to the thumbnail. let view = app.view().copied(); if let (Some(v), Some(cr)) = (view, app.canvas_rect) { - let box_points = view_box(region, thumb, cr, &v); + let box_points = view_box(region, thumb, cr, &v, app.canvas_ui_zoom); if box_points.len() > 2 { let color = app.ui.navigator_panel.view_box(); ui.painter().with_clip_rect(thumb).add(egui::Shape::closed_line(box_points, Stroke::new(1.5, color))); @@ -449,6 +450,54 @@ mod tests { assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{points:?} is not {want:?}"); } + /// With the interface zoomed by Preferences › User Interface › UI Scaling, the view box still + /// outlines what the canvas shows: at 400% and UI Scaling 1.5×, a canvas 600 interface points + /// wide shows 600 × 1.5 / 4 document points. + #[test] + fn the_view_box_outlines_the_canvas_at_every_ui_scaling() { + use vectorcraft_engine::Session; + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + app.canvas_rect = Some(canvas); + app.canvas_ui_zoom = ui_zoom; + app.run("view.setZoom", json!({"zoom": 400, "center": [200, 150]})).unwrap(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let pasteboard = Tokens::get(&ctx).pasteboard; + let thumb = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == pasteboard => Some(r.rect), + _ => None, + }) + .expect("the thumbnail"); + let red = egui::epaint::ColorMode::Solid(Color32::from_rgb(0xff, 0x30, 0x30)); + let drawn: Vec> = out + .shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Path(p) if p.closed && p.stroke.color == red => Some(p.points), + _ => None, + }) + .collect(); + let [points] = &drawn[..] else { panic!("UI Scaling {ui_zoom}×: one view box: {drawn:?}") }; + let region = CACHE.with(|c| c.borrow().region.unwrap().1); + // Each canvas corner is (its offset from the canvas's center) × UI Scaling / zoom + // document points from the view's center. + let k = f64::from(ui_zoom) / 4.0; + let want = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()].map(|c| { + let d = c - canvas.center(); + doc_to_thumb(region, thumb, Point::new(200.0 + f64::from(d.x) * k, 150.0 + f64::from(d.y) * k)) + }); + assert_eq!(points.len(), 4, "UI Scaling {ui_zoom}×: {points:?}"); + assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "UI Scaling {ui_zoom}×: {points:?} is not {want:?}"); + } + } + /// The panel menu's Panel Options… is enabled, and a click opens the Navigator Panel Options. #[test] fn panel_options_opens_from_the_panel_menu() { diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 4b5d4054a3..64daf1ac76 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -44,8 +44,7 @@ impl Flag { fn put(self, f: &mut OtFeatures, on: bool) { match self { Flag::Field(field) => *field(f) = on, - Flag::Extra(tag) if on => f.extra |= extra_bit(tag), - Flag::Extra(tag) => f.extra &= !extra_bit(tag), + Flag::Extra(tag) => f.set_extra(extra_bit(tag), on), } } } @@ -69,13 +68,16 @@ pub(crate) fn figure(f: &OtFeatures) -> usize { fn set_figure(f: &mut OtFeatures, i: usize) { f.tabular_figures = i == 1 || i == 4; f.oldstyle_figures = i >= 3; - f.extra &= !match i { - 1 => extra_bit("pnum"), - 3 => extra_bit("lnum"), - _ => extra_bit("pnum") | extra_bit("lnum"), - }; + f.set_extra( + match i { + 1 => extra_bit("pnum"), + 3 => extra_bit("lnum"), + _ => extra_bit("pnum") | extra_bit("lnum"), + }, + false, + ); if i == 2 { - f.extra |= extra_bit("pnum") | extra_bit("lnum"); + f.set_extra(extra_bit("pnum") | extra_bit("lnum"), true); } } @@ -95,11 +97,7 @@ pub(crate) fn position(f: &OtFeatures) -> Option { /// Default Position turns all four off. fn set_position(f: &mut OtFeatures, i: usize) { for (k, tag) in POSITION_TAGS.iter().enumerate() { - if k + 1 == i { - f.extra |= extra_bit(tag); - } else { - f.extra &= !extra_bit(tag); - } + f.set_extra(extra_bit(tag), k + 1 == i); } } diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index cabca387ae..7ad184ea99 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -877,6 +877,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Inseparable pairs stay on one line; only with a kinsoku set. + let (kinsoku_on, bunri) = bunri_kinshi_item(app); + if menu_item(ui, tl!("Bunri-Kinshi"), has && kinsoku_on, bunri) { + toggle_bunri_kinshi(app); + } // Korean lines break between any two syllables, not only between words. let anywhere = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); if menu_item(ui, tl!("Korean Line Break Anywhere"), has, anywhere) { @@ -904,6 +909,19 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { format(app, json!({"kinsokuType": key})); } +/// Bunri-Kinshi's menu item for the paragraphs the panel acts on ([`format`]): (available, checked). +/// It is available when none of them has kinsoku None, and checked when every one of them has it. +fn bunri_kinshi_item(app: &VectorcraftApp) -> (bool, bool) { + (shared_para(app, |p| p.kinsoku == Kinsoku::None) == Some(false), shared_para(app, |p| p.bunri_kinshi) == Some(true)) +} + +/// Bunri-Kinshi (Paragraph panel menu): a click turns it off for all the paragraphs the panel acts +/// on ([`format`]) when every one of them has it, and on for all of them otherwise, in one undo step. +fn toggle_bunri_kinshi(app: &mut VectorcraftApp) { + let on = shared_para(app, |p| p.bunri_kinshi) == Some(true); + format(app, json!({"bunriKinshi": !on})); +} + /// Korean Line Break Anywhere (Paragraph panel menu): the menu item is checked when every paragraph /// the panel acts on ([`format`]) has it, and a click turns it off for all of them then, and on for /// all of them otherwise, in one undo step. @@ -915,8 +933,8 @@ fn toggle_korean_line_break_anywhere(app: &mut VectorcraftApp) { /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification /// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi -/// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman -/// Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo +/// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, +/// Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo /// step named Reset Panel (inside an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); @@ -937,7 +955,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", - "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false, + "bunriKinshi": false }), ); para_cmd(app, "text.tabs.clear", json!({})); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 517ef1eeab..177ecd08fb 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -166,6 +166,48 @@ fn korean_line_break_anywhere_toggles_the_selected_paragraphs_in_one_undo_step() assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); } +/// Bunri-Kinshi: new type has it off; the menu item turns it on for the paragraphs the Type tool's +/// selection touches in one undo step, is unchecked while the selected paragraphs differ, and is +/// unavailable while any of them has kinsoku None, which keeps the setting. Reset Panel turns it off. +#[test] +fn bunri_kinshi_toggle_follows_kinsoku_and_the_selected_paragraphs() { + let (mut app, id) = editing_text("あ——い\nう……え\n"); + app.session.prefs.show_east_asian_options = true; + let flags = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).bunri_kinshi, text.para_at(1).bunri_kinshi) + }; + assert_eq!(flags(&app), (false, false), "new type has it off"); + select_range(&mut app, 0, 3); + assert_eq!(bunri_kinshi_item(&app), (true, false)); + let history = app.session.active().unwrap().history.undo.len(); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (true, false)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + assert_eq!(bunri_kinshi_item(&app), (true, true)); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (true, false), "mixed: unchecked"); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (true, true)); + // Kinsoku None on one paragraph: the item is unavailable and the setting stays. + let second = "あ——い\n".len(); + app.run("text.setFormat", json!({"id": id, "start": second, "end": second, "kinsoku": "none"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (false, true)); + assert_eq!(flags(&app), (true, true)); + app.run("text.setFormat", json!({"id": id, "kinsoku": "soft"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(bunri_kinshi_item(&app), (true, true)); + toggle_bunri_kinshi(&mut app); + assert_eq!(flags(&app), (false, false)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_bunri_kinshi(&mut app); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index f6ece0ac55..894b8488f8 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -100,10 +100,37 @@ pub(crate) fn edited_dash_pattern(fields: &[Option; 6], previous: &[f64]) - out } -fn arrow_label(a: Option) -> &'static str { +fn arrow_label(a: Option<&Arrowhead>) -> &'static str { a.map_or("None", Arrowhead::label) } +/// The name an arrowhead menu shows for `a`: a built-in head's in the interface language, a +/// custom head's own. +fn arrow_name(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => c.name.clone(), + _ => tl!(arrow_label(a)).to_string(), + } +} + +/// The preview cache key of `a`: its name, and for a custom head its art. +fn arrow_key(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in &c.path.subpaths { + (s.anchors.len(), s.closed).hash(&mut h); + for p in s.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]) { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + format!("custom:{}:{:x}", c.name, h.finish()) + } + _ => format!("{a:?}"), + } +} + /// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the /// selected characters' stroke. fn set(app: &mut VectorcraftApp, p: Value) { @@ -272,14 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Arrowheads. - let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow)); + let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow.clone()), st.as_ref().and_then(|s| s.end_arrow.clone())); + // The custom heads of the arrowheads file follow the built-in ones. + let custom: Vec = app.session.arrowheads.heads().iter().cloned().map(Arrowhead::Custom).collect(); ui.horizontal(|ui| { row_label(ui, tl!("Arrowheads:")); - if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) { - set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))})); + // A custom head goes with its art, as document.inspect reports it. + if let Some(a) = arrow_dropdown(ui, "arrow-start", sa.as_ref(), &custom, true) { + set(app, json!({ "startArrow": a })); } - if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) { - set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))})); + if let Some(a) = arrow_dropdown(ui, "arrow-end", ea.as_ref(), &custom, false) { + set(app, json!({ "endArrow": a })); } if widgets::icon_button_enabled(ui, "arrow-left-right", tl!("Swap start and end arrowheads"), false, st.is_some(), 22.0).clicked() { app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok(); @@ -337,22 +367,44 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left -/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). -pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Option { - let mut doc = Document::new(w, h); - let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5); - let st = ap.stroke_mut()?; +/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). A custom +/// head is made smaller than the built-in ones when it would not fit (below the smallest head +/// weight, its outline is scaled down), and the line's end moves in by as much as the head reaches +/// past its tip. +pub(super) fn arrow_doc(mut a: Option, start: bool, w: f64, h: f64) -> Option { + const WIDTH: f64 = 1.5; + // The smallest weight `StrokeLayer::arrow_weight` gives a head. + const LEAST: f64 = 0.25; // Heads of weight 2.5 pt: 10 pt long and wide. - st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0); + let mut scale: f64 = 500.0 / 3.0; + let mut inset = 0.0; + if let Some(Arrowhead::Custom(c)) = &a + && let Some(b) = c.path.control_bounds() + { + let (long, half) = (b.x1.max(0.0) - b.x0.min(0.0), b.y0.abs().max(b.y1.abs())); + let fit = |room: f64, units: f64| if units > 1e-9 { room.max(0.0) / units } else { f64::INFINITY }; + let weight = fit(w - 6.0, long).min(fit(h / 2.0 - 1.0, half)).min(2.5); + inset = -b.x0.min(0.0) * weight; + if weight < LEAST { + let path = c.path.transformed(vectorcraft_geom::Affine::scale(weight / LEAST)); + a = Some(Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(c.name.clone(), path)))); + } + scale = scale.min(weight.max(LEAST) / WIDTH * 100.0); + } + let mut doc = Document::new(w, h); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), WIDTH); + let st = ap.stroke_mut()?; + st.arrow_scale = (scale, scale); st.arrow_align = ArrowAlign::Tip; if start { st.start_arrow = a } else { st.end_arrow = a } + let (x0, x1) = if start { (3.0 + inset, w - 3.0) } else { (3.0, w - 3.0 - inset) }; let mut bp = vectorcraft_geom::BezPath::new(); - bp.move_to((3.0, h / 2.0)); - bp.line_to((w - 3.0, h / 2.0)); + bp.move_to((x0, h / 2.0)); + bp.line_to((x1, h / 2.0)); let (id, l) = (doc.alloc_id(), doc.layers[0].id); doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; Some(doc) @@ -360,14 +412,15 @@ pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Op /// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the /// left (`start`) or right end. -fn paint_arrow(ui: &Ui, r: Rect, a: Option, start: bool, color: Color32) { - if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) { +fn paint_arrow(ui: &Ui, r: Rect, a: Option<&Arrowhead>, start: bool, color: Color32) { + if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{}:{start}", arrow_key(a)), r.size(), |w, h| arrow_doc(a.cloned(), start, w, h)) { ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color); } } -/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice). -fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> Option> { +/// Dropdown whose button and items show drawn arrowhead previews: None, the built-in heads, then +/// the `custom` ones. Returns Some(choice). +fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arrowhead], start: bool) -> Option> { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); @@ -380,23 +433,27 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> egui::Popup::menu(&resp).id(egui::Id::unique(("arrow-pop", id))).show(|ui| { ui.set_min_width(170.0); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) { + let built_in = Arrowhead::ALL; + for a in std::iter::once(None).chain(built_in.iter().map(Some)).chain(custom.iter().map(Some)) { let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click()); if a == cur { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if rr.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } - paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + // Only the rows in view render their previews. + if ui.is_rect_visible(row) { + paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + } ui.painter().text( row.left_center() + vec2(66.0, 0.0), egui::Align2::LEFT_CENTER, - tl!(arrow_label(a)), + arrow_name(a), egui::FontId::proportional(11.5), t.text, ); if rr.clicked() { - out = Some(a); + out = Some(a.cloned()); ui.close(); } } @@ -500,6 +557,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Reset Profiles"), crate::menus::enabled(app, "stroke.widthProfile.reset"), false) { app.run("stroke.widthProfile.reset", json!({})).ok(); } + ui.separator(); + // Opens the arrowheads file, whose symbols are the custom heads; saving it reloads them. + if menu_item(ui, tl!("Edit Arrowheads…"), crate::menus::enabled(app, "stroke.arrowheads.edit"), false) { + app.run("stroke.arrowheads.edit", json!({})).ok(); + } } #[cfg(test)] @@ -647,7 +709,7 @@ mod tests { assert!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar"); let lens = WidthProfile::lens(); assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice())); - assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); + assert_eq!(arrow_label(Some(&Arrowhead::CircleOpen)), "Circle (open)"); } #[test] diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs index 22b542a3f0..d4360b4743 100644 --- a/crates/ui-egui/src/panels/swatches/dnd.rs +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -59,9 +59,10 @@ pub(super) enum Drop { /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it /// gives that swatch its paint instead of moving. Move { names: Vec, target: Option, after: bool, with: Option }, - /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row - /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch - /// `target` when that is of its kind and not a tint swatch. + /// A paint from elsewhere (a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's + /// thumbnail) dropped on row `target` (or between rows) becomes a swatch; with Alt held + /// (`replace`) it replaces swatch `target` when that is of its kind and neither Registration + /// nor a tint swatch. New { paint: Paint, target: Option, replace: bool }, /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). Pattern { ids: Vec }, @@ -259,7 +260,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { None => return, }, // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. - Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { + Drop::New { paint, target: Some(t), replace: true } + if d.swatch(&t).is_some_and(|w| !w.is_reserved() && w.tint_of().is_none() && same_kind(&w.paint, &paint)) => + { ("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)})) } Drop::New { paint, target, .. } => { @@ -279,9 +282,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } /// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, -/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance -/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the -/// thumbnail of a dragged graphic style. +/// a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's thumbnail), the fill of the +/// object whose appearance the Appearance panel's thumbnail carries (or of the first art dragged +/// off the canvas), or the thumbnail of a dragged graphic style. pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { let Some(d) = egui::DragAndDrop::payload::(ctx) else { return }; let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index c649e282ba..3586f91607 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -437,11 +437,24 @@ fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, tim /// [`drag`] with `modifiers` held. fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) { + drag_drawn(app, ctx, from, to, modifiers, time, show); +} + +/// [`drag_with`] over the panels `draw` draws. +fn drag_drawn( + app: &mut VectorcraftApp, + ctx: &egui::Context, + from: Pos2, + to: Pos2, + modifiers: Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), +) { let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; - frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show); - frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show); - frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show); - frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show); + frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, draw); + frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, draw); } fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec { @@ -553,6 +566,124 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { assert!(egui::DragAndDrop::payload::(&ctx).is_none()); } +/// The center of the Recent Colors chip `i` drawn by `draw` in a frame at `time`: the chip of the +/// panel's row, or (`color`) of the Color panel's row, drawn in a child whose scope id `draw` keeps +/// in the panel state `"test-color-scope"`. +fn recent_chip(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui), color: bool) -> Pos2 { + let mut chip = None; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect, ..Default::default() }, |ui| { + draw(app, ui); + let scope = if color { pstate::>(ctx, "test-color-scope").expect("the Color panel is drawn") } else { ui.scope_id() }; + chip = ctx.read_response(scope.with(("recent", i))).map(|r| r.rect.center()); + }); + out.textures_delta.clear(); + chip.unwrap_or_else(|| panic!("recent chip {i} is drawn")) +} + +/// A rectangle filled with a CMYK color and then an RGB one, so Recent Colors holds the RGB color +/// first and the CMYK one second; → its id and the CMYK color. +fn art_with_two_recent_colors(app: &mut VectorcraftApp) -> (vectorcraft_doc::NodeId, Color) { + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": {"c": 10, "m": 60, "y": 20, "k": 5}, "keepModel": true})).unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": "#2050a0"})).unwrap(); + let cmyk = app.session.recent_colors[1]; + assert!(matches!(cmyk, Color::Cmyk { .. }), "the second recent color is CMYK: {cmyk:?}"); + (vectorcraft_doc::NodeId(id), cmyk) +} + +/// The fill of art `id`. +fn fill_of(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> Option { + app.session.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint()) +} + +/// A Recent Colors chip dragged onto the swatch list becomes a swatch of its color, in its color +/// model, as one undo step; the drag paints no art, and a color that is already a swatch is added +/// again under another name. +#[test] +fn a_recent_color_dragged_onto_the_list_becomes_a_swatch() { + let mut app = app(); + let ctx = context(); + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let art = fill_of(&app, id); + let undo = app.session.doc().unwrap().history.undo.len(); + let (count, brights) = (app.session.doc().unwrap().doc.swatches_iter().count(), names_of(&app, Some("Brights")).len()); + // Onto a grouped swatch: into that group, at its end. + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Bright Red"), 1.0); + let d = app.session.doc().unwrap(); + let group = &d.doc.swatch_groups.iter().find(|g| g.name == "Brights").unwrap().swatches; + assert_eq!(group.len(), brights + 1, "{}", app.ui.status); + let first = group.last().cloned().expect("a swatch"); + assert_eq!(first.paint, Paint::solid(cmyk), "the chip's color, still CMYK"); + assert_eq!(d.history.undo.len(), undo + 1, "one undo step"); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + assert!(egui::DragAndDrop::payload::(&ctx).is_none()); + // The same color again, onto an ungrouped swatch: a second swatch under another name. + frame(&mut app, &ctx, vec![], 2.0, show); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Red"), 3.0); + let newest = app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch"); + assert_eq!(newest.paint, Paint::solid(cmyk)); + assert_ne!(newest.name, first.name, "another name"); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 2); + // Undo takes the last one off. + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1); +} + +/// A color Alt-dropped on Registration, which can't be edited, becomes a new swatch and leaves +/// Registration as it is. +#[test] +fn a_color_alt_dropped_on_registration_becomes_a_new_swatch() { + let mut app = app(); + let ctx = context(); + let (_, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let registration = app.session.doc().unwrap().doc.swatch(REGISTRATION).map(|w| w.paint.clone()); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag_with(&mut app, &ctx, chip, tile_center(&ctx, REGISTRATION), Modifiers::ALT, 1.0); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(d.swatch(REGISTRATION).map(|w| w.paint.clone()), registration, "Registration is unchanged"); +} + +/// A Recent Colors chip of the Color panel dragged onto the Swatches panel becomes a swatch; a click +/// on it still paints the selected art. +#[test] +fn a_recent_color_dragged_from_the_color_panel_becomes_a_swatch() { + fn both(app: &mut VectorcraftApp, ui: &mut Ui) { + show(app, ui); + ui.push_id("color", |ui| { + crate::panels::color::show(app, ui); + set_pstate(ui.ctx(), "test-color-scope", Some(ui.scope_id())); + }); + } + let mut app = app(); + let ctx = context(); + app.ui.color_show_recent_colors = true; + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, both); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let art = fill_of(&app, id); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, both, true); + drag_drawn(&mut app, &ctx, chip, tile_center(&ctx, "Red"), Modifiers::NONE, 1.0, both); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + // A click on the chip paints the selected art. + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + frame(&mut app, &ctx, vec![], 2.0, both); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, both, true); + let button = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(chip), button(true), button(false)], 3.0, both); + assert_eq!(fill_of(&app, id).and_then(|f| f.color()), Some(cmyk), "a click paints the art"); +} + #[test] fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 6a984a1ced..77dd07e15f 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -84,11 +84,10 @@ fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &m /// one frame ends and the next begins, the last paragraph of the one and the first of the next are /// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece /// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with -/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again. -/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty -/// paragraph takes its style from its last piece when the story flows again, so it is targeted by -/// that piece when `selected` takes its frame, and otherwise not. -fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec) { +/// text, the piece whose style the story keeps when it flows again. An empty paragraph takes its +/// style from its last piece when the story flows again, so it is targeted by that piece. Locked and +/// hidden frames are targeted like the others. +fn append_story_targets(doc: &Document, story: &[NodeId], targets: &mut Vec) { /// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`. struct Piece<'a> { id: NodeId, @@ -113,11 +112,7 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } } for pieces in ¶graphs { - let pick = if pieces.iter().any(|p| p.has_text) { - pieces.iter().rev().find(|p| p.has_text && selected(p.id)) - } else { - pieces.last().filter(|p| selected(p.id)) - }; + let pick = pieces.iter().rev().find(|p| p.has_text).or_else(|| pieces.last()); if let Some(p) = pick { targets.push(indent_target(p.id, p.text, p.index, p.start)); } @@ -125,13 +120,14 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } /// Paragraphs touched by the Type selection (every paragraph of the story after Select All in -/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story -/// is listed once, in story order ([`append_story_targets`]). +/// threaded text), or all paragraphs of selected text objects. A selected frame of a threaded story +/// selects the whole story, its locked and hidden frames included. Each paragraph of a threaded +/// story is listed once, in story order ([`append_story_targets`]). fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { let mut targets = Vec::new(); if let Some((_, a, b)) = super::character::text_editing(app) { if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) { - append_story_targets(&st.doc, &story, |_| true, &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } else if let NodeKind::Text(text) = &node.kind { append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); } @@ -153,7 +149,7 @@ fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { } let story = st.doc.story_of(id); done.extend(story.iter().copied()); - append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } } targets @@ -243,9 +239,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma } } -/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values -/// of their own get one command each, the story's last paragraph first: threaded text re-flows -/// after each command, and a re-flow moves only text after the paragraph that changed. +/// Apply an absolute ruler position through paragraph-format commands. When the paragraphs need +/// values of their own, one command sets each paragraph's ([`set_each_indent`]), and threaded text +/// flows again once. fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { if targets.is_empty() || !target.is_finite() { return; @@ -253,23 +249,10 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return }; if marker == IndentMarker::Upper { if targets.iter().all(|p| p.left == para.left_indent) { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) { - app.status(e); - } + run_on_targets(app, targets, json!({"firstLineIndent": target - para.left_indent})); } else { - super::character::end_typing(app); - let commands: Vec = targets - .iter() - .rev() - .map(|p| { - json!({"command": "text.setFormat", "params": { - "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left - }}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, None, Some(target - p.left))).collect(); + set_each_indent(app, tl!("First-line Left Indent"), changes); } } else { // The ruler shows each paragraph's left indent with its list level's added; the paragraph @@ -284,26 +267,58 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: _ => None, }; if let Some(params) = params { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { - app.status(e); - } + run_on_targets(app, targets, params); return; } - super::character::end_typing(app); - let commands: Vec = targets - .iter() - .rev() - .map(|p| { - let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level}); - if !both { - params["firstLineIndent"] = json!(p.left + p.first_line - target); - } - json!({"command": "text.setFormat", "params": params}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, Some(target - p.level), (!both).then_some(p.left + p.first_line - target))).collect(); + set_each_indent(app, tl!("Left Indent"), changes); + } +} + +/// Run one `text.setFormat` with `params` on the paragraphs the Paragraph panel acts on while typing, +/// and with the Selection tool on every type object of `targets`, so a selected frame of a threaded +/// story sets the whole story ([`indent_targets`]). +fn run_on_targets(app: &mut VectorcraftApp, targets: &[IndentTarget], mut params: Value) { + if super::character::text_editing(app).is_none() { + let mut seen = HashSet::new(); + params["ids"] = json!(targets.iter().map(|p| p.id).filter(|id| seen.insert(*id)).collect::>()); + } + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { + app.status(e); + } +} + +/// Give each paragraph its own left and first-line indents (None: as it is) in one `text.setFormat` +/// with `paragraphIndents`, one undo step named `label`. Paragraphs that keep their indents are +/// left out, and when every paragraph keeps them nothing is run. +fn set_each_indent(app: &mut VectorcraftApp, label: &str, changes: Vec<(&IndentTarget, Option, Option)>) { + // As `text.setFormat` keeps a paragraph's indents, within 1296 pt either way. + let bound = |v: Option| v.map(|v| v.clamp(-1296.0, 1296.0)); + let entries: Vec = changes + .into_iter() + .filter_map(|(p, left, first_line)| { + let (left, first_line) = (bound(left), bound(first_line)); + let own_left = p.left - p.level; + if left.is_none_or(|v| v == own_left) && first_line.is_none_or(|v| v == p.first_line) { + return None; + } + let mut entry = json!({"id": p.id, "start": p.start}); + if let Some(v) = left { + entry["leftIndent"] = json!(v); + } + if let Some(v) = first_line { + entry["firstLineIndent"] = json!(v); + } + Some(entry) + }) + .collect(); + if entries.is_empty() { + return; + } + super::character::end_typing(app); + let command = json!({"command": "text.setFormat", "params": {"paragraphIndents": entries}}); + if let Err(e) = app.run("command.batch", json!({"label": label, "commands": [command]})) { + app.status(e); } } @@ -346,7 +361,7 @@ fn place_above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, spa /// Screen points per point of text `n` at the canvas zoom, as [`above_text`] sizes the ruler: /// across the frame for area type, the zoom for point type. fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let per = match &n.kind { NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => f64::from(xf.rect_to_screen(n.geometric_bounds()?).width()) / span, _ => xf.zoom, @@ -358,7 +373,7 @@ fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option { /// put 0 on the text's left edge and its marks at the canvas zoom, so each stop sits over the /// column it sets (#729). `span`: the ruler's length in the text's points. fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64) -> Option<()> { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let on_screen = xf.rect_to_screen(n.geometric_bounds()?); // Screen points per point of the text: across the frame for area type (so a scaled frame // lines up too), the zoom for point type. diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 97e8c8a00a..d6bcef15f1 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -617,6 +617,52 @@ fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_p } } +/// A marker drag over many paragraphs of a threaded story with mixed indents sends one +/// `text.setFormat` for all of them, in one undo step, and a drag that changes no paragraph runs +/// nothing. +#[test] +fn indent_markers_over_many_threaded_paragraphs_send_one_format_command() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 3000})).unwrap(); + let story = (1..=128).map(|i| format!("p{i}")).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 2900]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let before = story_paragraphs(&app, &[a, b]); + assert_eq!(before.len(), 128, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); + let drag = |app: &mut VectorcraftApp, marker, to| { + let (node, para, span) = current_para(app).unwrap(); + let targets = indent_targets(app, &node); + assert_eq!(targets.len(), 128); + set_marker(app, marker, to, ¶, span, &targets, false); + }; + let (undo, journal) = steps(&app); + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1)); + let (name, params) = app.session.journal.last().cloned().unwrap(); + assert_eq!(name, "command.batch"); + let commands = params["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 1, "one command for every paragraph"); + assert_eq!(commands[0]["command"], "text.setFormat"); + assert_eq!(commands[0]["params"]["paragraphIndents"].as_array().unwrap().len(), 128); + let after = story_paragraphs(&app, &[a, b]); + for (was, now) in before.iter().zip(&after) { + assert_eq!((&now.0, now.1, now.2), (&was.0, was.1, 60.0), "{after:?}"); + } + // The same position again changes no paragraph. + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1), "nothing ran"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), after); +} + /// After Select All in threaded text, a paragraph split between two frames is one target. #[test] fn indent_markers_after_select_all_list_a_split_paragraph_once() { @@ -633,12 +679,12 @@ fn indent_markers_after_select_all_list_a_split_paragraph_once() { assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); } -/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in -/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that -/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is -/// listed with the story's frames, and an empty text object keeps its one paragraph. +/// With the Selection tool on any frame of a threaded story, the indent markers list every +/// paragraph of the story, each once in story order: a selected story is one text. Text in a +/// selected group is listed with the story's frames, and an empty text object keeps its one +/// paragraph. #[test] -fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { +fn indent_markers_on_some_threaded_frames_list_the_whole_story_once() { let (mut app, [a, b, c]) = split_story_app(); let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count(); let targets_of = |app: &mut VectorcraftApp, ids: Vec| { @@ -646,29 +692,64 @@ fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { let (node, _, _) = current_para(app).unwrap(); story_positions(&indent_targets(app, &node), &[a, b, c]) }; - // The continuing frame alone: its first paragraph is the one the frame before ends inside. - let only_b = targets_of(&mut app, vec![b]); - assert_eq!(only_b.len(), count(&app, b), "{only_b:?}"); - assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}"); - let only_a = targets_of(&mut app, vec![a]); - assert_eq!(only_a.len(), count(&app, a), "{only_a:?}"); - // Two frames that share a paragraph list it once, by the later frame's piece. - let both = targets_of(&mut app, vec![c, a, b]); - assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}"); - assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}"); - // The first and last frames of the story, and a grouped empty text object besides. + let story = count(&app, a) + count(&app, b) + count(&app, c) - 2; + let all = targets_of(&mut app, vec![c, a, b]); + assert_eq!(all.len(), story, "two frames that share a paragraph list it once: {all:?}"); + assert!(all.windows(2).all(|w| w[0] < w[1]), "{all:?}"); + for frame in [a, b, c] { + assert_eq!(targets_of(&mut app, vec![frame]), all, "frame {frame} alone lists the whole story"); + } + // A grouped empty text object besides the last frame of the story. let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap(); app.run("select.set", json!({"ids": [empty, c]})).unwrap(); let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [group, a]})).unwrap(); - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().clone(); let targets = indent_targets(&app, &node); let ids: Vec = targets.iter().map(|p| p.id).collect(); assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}"); - let story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); - let positions = story_positions(&story, &[a, b, c]); - assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}"); - assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); + let in_story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); + assert_eq!(story_positions(&in_story, &[a, b, c]), all); +} + +/// A marker drag on one selected frame of a threaded story sets every paragraph of the story, in +/// its locked and hidden frames too, in one undo step, whether the paragraphs share their indents +/// or have their own. +#[test] +fn indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + // Every paragraph has the same indents. + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 30.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let shared = story_paragraphs(&app, &[a, b, c]); + assert_eq!(shared.len(), 12, "{shared:?}"); + assert!(shared.iter().all(|p| (p.1, p.2) == (0.0, 30.0)), "{shared:?}"); + // The paragraphs of the hidden frame have a left indent of their own. + app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let before = story_paragraphs(&app, &[a, b, c]); + assert_eq!(before.len(), 12, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let after = story_paragraphs(&app, &[a, b, c]); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0); + } } #[test] diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 157b58d3cb..263342741a 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -120,6 +120,26 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() { assert!(last_open(&app.ui).is_none()); } +#[test] +fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { + let mut app = app(); + let ctx = context(); + 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, ""); + 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)); + click(&mut app, &ctx, "Pooling Wash", Modifiers::NONE, 0.0); + assert_eq!((count(&app), undo_len(&app)), (brushes + 1, undo + 1), "one brush, one undo step"); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(path)).unwrap().clone(); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Pooling Wash")); + click(&mut app, &ctx, "Brush Pen", Modifiers::NONE, 2.0); + assert!(has(&app, "Brush Pen")); + assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen"); +} + #[test] fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let mut app = app(); @@ -131,6 +151,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { opened, [ json!("art-ribbons"), + json!("ink-and-wash"), json!("calligraphic-nibs"), json!("bristle-brushes"), json!("pattern-borders"), diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index e37b6e75b7..1d80cc1c66 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -100,9 +100,52 @@ fn every_arrowhead_preview_draws_its_head() { }; let plain = (ink(None, false), ink(None, true)); for a in Arrowhead::ALL { - assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end"); - assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start"); + assert!(ink(Some(a.clone()), false) > plain.0 + 20, "{a:?} at the end"); + assert!(ink(Some(a.clone()), true) > plain.1 + 20, "{a:?} at the start"); } + // So does a custom head: a 4 × 4 square behind its tip. + let pts = [(0.0, -2.0), (4.0, -2.0), (4.0, 2.0), (0.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let square = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let custom = Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Square".into(), square))); + assert!(ink(Some(custom.clone()), false) > plain.0 + 20); + assert!(ink(Some(custom), true) > plain.1 + 20); +} + +#[test] +fn a_long_custom_arrowhead_preview_fits_in_its_box() { + use vectorcraft_doc::Arrowhead; + // 40 units long and 4 wide, reaching 4 units past its tip. + let pts = [(-4.0, -2.0), (36.0, -2.0), (36.0, 2.0), (-4.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let bar = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + // 120 units across: smaller than the smallest head weight can draw it. + let pts = [(0.0, -60.0), (4.0, -60.0), (4.0, 60.0), (0.0, 60.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let wall = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let heads = [bar, wall].map(|p| Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Bar".into(), p)))); + for (head, start) in heads.iter().flat_map(|h| [(h, false), (h, true)]) { + let d = stroke::arrow_doc(Some(head.clone()), start, 56.0, 22.0).unwrap(); + // Rendered well beyond the 56 × 22 pt box, at 1 pixel a point: all the ink is inside it. + let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(-100.0, -50.0, 156.0, 72.0), 1.0, false); + let ink: Vec<(u32, u32)> = (0..256).flat_map(|x| (0..122).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).collect(); + assert!(ink.len() > 40, "start {start}: the line and head draw"); + let outside: Vec<&(u32, u32)> = ink.iter().filter(|&&(x, y)| !(100..156).contains(&x) || !(50..72).contains(&y)).collect(); + assert!(outside.is_empty(), "start {start}: {} pixels outside the box, e.g. {:?}", outside.len(), outside.first()); + } +} + +#[test] +fn the_arrowhead_menu_renders_only_the_previews_in_view() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + let label = text_rect(&t, "Arrowheads:"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + let previews = ctx.tex_manager().read().allocated().filter(|(_, m)| m.name.contains(":arrow:") && m.name.ends_with(":false")).count(); + // The button's and the rows' in the 360 pt list, not one for each of the menu's heads. + let rows = vectorcraft_doc::Arrowhead::ALL.len() + 1; + assert!(previews > 5 && previews < rows / 2 + 5, "{previews} previews for {rows} rows"); } #[test] diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 3c94bf88a5..29751dd5d7 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -401,6 +401,48 @@ fn a_mixed_revolve_cap_shows_a_dash() { assert_eq!(undo_len(&app), history); } +/// Quick Actions › Expand as Wireframes shows for every object type, and turns the selected 3D +/// object into a group of stroked edges in one undo step. +#[test] +fn expand_as_wireframes_replaces_the_object_with_its_edges() { + for kind in ["extrude", "inflate", "revolve", "plane"] { + let mut app = app(); + set_type(&mut app, kind).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains("Quick Actions") && text.contains("Expand as Wireframes"), "{kind}: {text}"); + let id = app.session.active().unwrap().selection.objects[0]; + let history = undo_len(&app); + super::expand_as_wireframes(&mut app).unwrap(); + assert_eq!(undo_len(&app), history + 1, "{kind}"); + let st = app.session.active().unwrap(); + assert!(matches!(st.doc.node(id).unwrap().kind, vectorcraft_doc::NodeKind::Group { .. }), "{kind}"); + assert_eq!(st.selection.objects, vec![id], "{kind}"); + // The group has no 3D effect: the panel offers the object types again. + assert!(target(&app).is_none(), "{kind}"); + } + // Without a 3D effect in the selection, nothing changes. + let mut app = app(); + let history = undo_len(&app); + assert!(super::expand_as_wireframes(&mut app).is_err()); + assert_eq!(undo_len(&app), history); +} + +/// The Quick Actions show on the Object tab of the effects that have one (Revolve (Classic) +/// included) and not on their Rotation tab; Revolve and Rotate show them on their first tab. +#[test] +fn quick_actions_show_on_the_object_tab_only() { + use crate::state::Dialog; + use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, REVOLVE}; + for effect in [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE] { + for (tab, shown) in [("object", true), ("rotation", false)] { + let d = Dialog::new("x", json!({"__effect": effect, "__tab": tab, "__gizmo": "rotation"})); + assert_eq!(super::quick_actions_shown(&d), shown, "{effect} {tab}"); + } + } + let d = Dialog::new("x", json!({"__effect": REVOLVE, "__tab": "rotation", "__gizmo": "rotation"})); + assert!(super::quick_actions_shown(&d)); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 25dfaa6842..c9f2f2205e 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -7,11 +7,12 @@ use egui::Ui; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; -use vectorcraft_effects::{THREE_D_TYPES, is_three_d}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; use super::{live_run, pstate, set_pstate}; use crate::VectorcraftApp; use crate::state::Dialog; +use crate::theme::Tokens; use crate::widgets::{self, Live, dim_label}; /// The panel's id (`window.panel`). @@ -242,6 +243,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let changed = changed_settings(&before, &after); let dragging = ctx.input(|i| i.pointer.any_down()); send(app, &ctx, changed, dragging); + if quick_actions_shown(&d) { + ui.add_space(10.0); + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Quick Actions")).font(crate::theme::semibold(13.0)).color(t.text)); + let tip = tl!("Replace the selected 3D objects with the edges of their faces, as stroked paths."); + if ui.button(tl!("Expand as Wireframes")).on_hover_text(tip).clicked() { + finish(app, &ctx); + if let Err(e) = expand_as_wireframes(app) { + app.ui.status = e; + } + } + } +} + +/// Does the panel show its Quick Actions? On the Object tab of the effects that have one, and on +/// the first tab of the others (Revolve, Rotate), never on Lighting. +fn quick_actions_shown(d: &Dialog) -> bool { + let effect = d.str("__effect"); + let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE, CLASSIC_REVOLVE].contains(&effect.as_str()); + !crate::dialogs::revolve_gizmo::lighting(d) && (!object_tab || d.str("__tab") != "rotation") +} + +/// Quick Actions › Expand as Wireframes: each selected object with a 3D effect becomes a group of +/// the edges of its faces (`effect.expandAsWireframes`), one undo step. +pub(crate) fn expand_as_wireframes(app: &mut VectorcraftApp) -> Result<(), String> { + app.run("effect.expandAsWireframes", json!({})).map(|_| ()) } /// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index de9407e2f3..1169422789 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -451,7 +451,7 @@ impl VectorcraftApp { let at = match pos { _ if io::opens_as(name).is_some() || fileio::opens_with_importer(name) => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, - Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), + Some(p) => crate::canvas::Xf::at(rect, v, self.canvas_ui_zoom).to_doc(p), None if is_document(name) => return DropTarget::Open, None => v.center, }; diff --git a/crates/ui-egui/src/placing/tests.rs b/crates/ui-egui/src/placing/tests.rs index 96492a6512..9afe94b9ee 100644 --- a/crates/ui-egui/src/placing/tests.rs +++ b/crates/ui-egui/src/placing/tests.rs @@ -587,7 +587,8 @@ fn a_deferred_swatch_paste_rejects_a_changed_token_or_closed_document() { &crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "__deferredPaste", "value": 999999})).0, ); let crate::control::Outcome::Done(result) = result else { panic!("expected dialog result") }; - assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["error"], "the `swatchConflict` dialog cannot set protected field `__deferredPaste`", "{result}"); + app.ui.dialog.as_mut().unwrap().fields.insert("__deferredPaste".into(), json!(999999)); } let error = crate::dialogs::confirm(&mut app).unwrap_err(); assert!( diff --git a/crates/ui-egui/src/screen_color.rs b/crates/ui-egui/src/screen_color.rs index 9b81405489..bf17c9544b 100644 --- a/crates/ui-egui/src/screen_color.rs +++ b/crates/ui-egui/src/screen_color.rs @@ -1,9 +1,10 @@ //! Sample Color from Screen (`ui.sampleScreenColor`): the system's color sampler picks a color -//! anywhere on the screen ([`crate::Services::screen_color`]: `NSColorSampler` on macOS, the -//! browser's EyeDropper where the browser has one). The command shows the sampler and answers at -//! once. The color arrives in a later frame ([`poll`]) and becomes the fill or the stroke through -//! `paint.setFill` / `paint.setStroke` (one undo step), or the open Color Picker's color. When the -//! person cancels the sampler (Escape), the colors stay as they were. +//! anywhere on the screen ([`crate::Services::screen_color`]: `NSColorSampler` on macOS, the XDG +//! desktop portal's `PickColor` on Linux, the browser's EyeDropper where the browser has one). The +//! command shows the sampler and answers at once. The color arrives in a later frame ([`poll`]) +//! and becomes the fill or the stroke through `paint.setFill` / `paint.setStroke` (one undo step), +//! or the open Color Picker's color. When the person cancels the sampler (Escape on macOS and in +//! browsers), the colors stay as they were. //! //! The sampler answers in sRGB (macOS clips a color outside sRGB to it). [`sampled`] converts that //! into the working RGB space, so when the working space is not sRGB, a color inside sRGB looks as diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 2a1ffdb5d2..676a9a48c8 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -160,9 +160,10 @@ pub struct Services { pub open_in_photocraft: Option, /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). pub pick_folder: Option Option>>, - /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and - /// browsers with an EyeDropper): Sample Color from Screen ([`screen_color`]). Without it that - /// command answers with an error and the Color Picker's eyedropper button is gray. + /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, Linux + /// through the desktop portal, and browsers with an EyeDropper): Sample Color from Screen + /// ([`screen_color`]). Without it that command answers with an error and the Color Picker's + /// eyedropper button is gray. pub screen_color: Option, /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 7ada99ee5c..4e02180017 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -358,6 +358,9 @@ pub struct Entry { /// Menu path ("Object › Arrange") or "Tools". pub group: String, pub is_tool: bool, + /// Keyboard Shortcuts adds the key to the entry's name: another entry has the same group and + /// label ([`distinct_names`]). + pub shows_key: bool, } /// The editor's entries, the Effect menu's effect items and Object › Plug-ins' filter items while @@ -419,20 +422,34 @@ fn build_snapshot(revision: u64) -> Arc { let effects: Arc<[EffectItem]> = build_effect_items().into(); let filters: Arc<[FilterItem]> = build_filter_items().into(); let (head, tail) = fixed_entries(); - let entries: Arc<[Entry]> = head + let mut entries: Vec = head .iter() .cloned() - .chain(effects.iter().map(|e| Entry { key: format!("effect:{}", e.effect), label: e.label.clone(), group: e.group.clone(), is_tool: false })) - .chain(filters.iter().map(|f| Entry { key: format!("plugin:{}", f.id), label: f.label.clone(), group: f.group.clone(), is_tool: false })) + .chain(effects.iter().map(|e| Entry { + key: format!("effect:{}", e.effect), + label: e.label.clone(), + group: e.group.clone(), + is_tool: false, + shows_key: false, + })) + .chain(filters.iter().map(|f| Entry { + key: format!("plugin:{}", f.id), + label: f.label.clone(), + group: f.group.clone(), + is_tool: false, + shows_key: false, + })) .chain(vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false, + shows_key: false, })) .chain(tail.iter().cloned()) .collect(); - Arc::new(Snapshot { revision, effects, filters, entries }) + distinct_names(&mut entries); + Arc::new(Snapshot { revision, effects, filters, entries: entries.into() }) } /// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. @@ -446,39 +463,65 @@ fn fixed_entries() -> &'static (Vec, Vec) { static E: OnceLock<(Vec, Vec)> = OnceLock::new(); E.get_or_init(|| { let mut v: Vec = vectorcraft_tools::catalog::all_tools() - .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) + .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true, shows_key: false }) .collect(); let mut seen = HashSet::new(); + // The commands listed only because an item of the menu bar runs them as a key press would + // (Set Corners…). + let mut by_the_menu_bar = HashSet::new(); for c in vectorcraft_engine::command_specs() { - // Commands outside the menus (panel menu items) are - // listed when a key alone can run them; queries and aliases never are. - let listed = if c.menu.is_empty() { - c.shortcut.is_some() || (c.journal && !vectorcraft_engine::cmd::is_alias(c.id) && runs_without_params(c.params)) - } else { - true - }; - if !listed || !seen.insert(c.id) { + let own = listed_on_its_own(c); + if !(own || a_menu_bar_item_runs(c.id, c.params)) || !seen.insert(c.id) { continue; } + if !own { + by_the_menu_bar.insert(c.id); + } let group = if c.menu.is_empty() { id_group(c.id) } else { c.menu.join(" › ") }; - v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false }); + v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false, shows_key: false }); } v.extend(crate::state::all_panels().map(|(id, label)| Entry { key: format!("panel:{id}"), label: label.to_string(), group: "Window".into(), is_tool: false, + shows_key: false, })); // Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`]. let mut tail = vec![]; for (id, label, sc, params) in menus::UI_COMMANDS { - // Rename has no default key, and a key press names the selection: it needs no `id`. - let key_runs_it = !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || *id == "ui.rename"; - if !seen.insert(id) || !key_runs_it { + let own = ui_listed_on_its_own(id, sc, params); + if !seen.insert(id) || !(own || a_menu_bar_item_runs(id, params)) { continue; } - tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); + if !own { + by_the_menu_bar.insert(id); + } + tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false, shows_key: false }); } + // A command listed only because an item of the menu bar runs it can have the group and + // label of another entry. When that entry is an engine command that no item of the menu bar + // runs, the command the item runs keeps the name and the other entry is shown in the group + // of its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, + // and `document.exportSelection` shows as Document › Export Selection…. Otherwise the + // command is left out: Window › Learn stays the Learn panel's entry. Entries that still + // have the same name, plug-ins included, are given names of their own by + // [`distinct_names`]. + let name = |e: &Entry| (e.group.clone(), e.label.clone()); + let new_names: HashSet<_> = v.iter().chain(&tail).filter(|e| by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); + for e in v.iter_mut().chain(tail.iter_mut()) { + if new_names.contains(&name(e)) + && !by_the_menu_bar.contains(e.key.as_str()) + && vectorcraft_engine::find_command(&e.key).is_some() + && !menu_bar_items().contains_key(e.key.as_str()) + { + e.group = id_group(&e.key); + } + } + let names: HashSet<_> = v.iter().chain(&tail).filter(|e| !by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); + let keep = |e: &Entry| !by_the_menu_bar.contains(e.key.as_str()) || !names.contains(&name(e)); + v.retain(keep); + tail.retain(keep); (v, tail) }) } @@ -598,9 +641,21 @@ pub(crate) fn shown_group(group: &str) -> String { group.split(" › ").map(|s| tl!(s)).collect::>().join(" › ") } -/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu. +/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu, and +/// an entry that [`Entry::shows_key`] with its key after the name. pub(crate) fn shown_name(e: &Entry) -> String { - if e.is_tool || e.group.is_empty() { shown_label(e).to_string() } else { format!("{} › {}", shown_group(&e.group), shown_label(e)) } + name_of(e, &shown_group(&e.group), shown_label(e)) +} + +/// [`shown_name`] in English, for a status or error message, which is translated when shown. +fn english_name(e: &Entry) -> String { + name_of(e, &e.group, &e.label) +} + +/// Entry `e`'s name with its group and label as `group` and `label`. +fn name_of(e: &Entry, group: &str, label: &str) -> String { + let name = if e.is_tool || e.group.is_empty() { label.to_string() } else { format!("{group} › {label}") }; + if e.shows_key { format!("{name} ({})", e.key) } else { name } } /// The group of a command no menu holds: the first part of its id, capitalised (`edit.x` → "Edit"). @@ -609,6 +664,107 @@ fn id_group(id: &str) -> String { c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() } +/// Gives entries that have the same group and label names of their own, and leaves the others as +/// they are. It runs on the whole snapshot, plug-ins included, after [`fixed_entries`] has shown an +/// engine command in the group of its id or left out a command listed only because an item of the +/// menu bar runs it: +/// 1. A command that an item of the menu bar runs, as a key press would run it, is shown under +/// that item's menu path ([`menu_bar_path`]): the four Other Library… commands go under Window › +/// Swatch Libraries, Brush Libraries, Graphic Style Libraries and Symbol Libraries. +/// 2. A command whose id is another command's id with one more part is shown under that command's +/// label: Reset Panel goes under Character Style Options and Paragraph Style Options. +/// 3. Entries that still have the same name show their key after it ([`Entry::shows_key`]), except +/// the one entry an item of the menu bar runs: File › New… stays the New Document dialog's +/// entry, and `file.new` shows as File › New… (file.new). An entry whose name is then the name +/// another entry has with its key (a plug-in named "Blur (plugin:a)") shows its key too. +fn distinct_names(entries: &mut [Entry]) { + // The entries of each name that more than one entry has. + fn shared(entries: &[Entry]) -> Vec> { + let mut by_name: HashMap> = HashMap::new(); + for (i, e) in entries.iter().enumerate() { + by_name.entry(english_name(e)).or_default().push(i); + } + by_name.into_values().filter(|list| list.len() > 1).collect() + } + for i in shared(entries).into_iter().flatten() { + let Some(e) = entries.get_mut(i).filter(|e| !e.is_tool) else { continue }; + if let Some(path) = menu_bar_path(&e.key) { + e.group = path.to_string(); + } else if let Some((parent, _)) = e.key.rsplit_once('.').and_then(|(parent, _)| command_doc(parent)) { + e.group = parent.trim_end_matches('…').to_string(); + } + } + // A pass gives every entry of a shared name but one its key, so a name is shared again only by + // an entry whose label ends with another entry's key; one pass per entry is enough. + for _ in 0..entries.len() { + let lists = shared(entries); + if lists.is_empty() { + break; + } + for list in lists { + let plain: Vec = list.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.shows_key)).collect(); + let on_the_menu_bar: Vec = + plain.iter().copied().filter(|&i| entries.get(i).is_some_and(|e| !e.is_tool && menu_bar_path(&e.key).is_some())).collect(); + // The entry an item of the menu bar runs keeps the plain name, unless another entry + // already shows that name with its key. + let keeps = on_the_menu_bar.first().copied().filter(|_| on_the_menu_bar.len() == 1 && plain.len() == list.len()); + for i in plain { + if let Some(e) = entries.get_mut(i) + && keeps != Some(i) + { + e.shows_key = true; + } + } + } + } +} + +/// The (label, params doc) of engine or UI command `id`. +fn command_doc(id: &str) -> Option<(&'static str, &'static str)> { + vectorcraft_engine::find_command(id).map(|c| (c.label, c.params)).or_else(|| menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.3))) +} + +/// The (menu path, params) of each item of the menu bar that [`menu_bar_items`] reads, by command +/// id: "Window › Swatch Libraries" for Window › Swatch Libraries › Other Library…. +fn menu_bar_paths() -> &'static HashMap<&'static str, Vec<(String, Value)>> { + fn walk(items: &[menus::Item], path: &mut Vec, out: &mut HashMap<&'static str, Vec<(String, Value)>>) { + for it in items { + match it { + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((path.join(" › "), p.clone())), + menus::Item::Sub(name, children) => { + path.push(name.to_string()); + walk(children, path, out); + path.pop(); + } + _ => {} + } + } + } + static PATHS: OnceLock>> = OnceLock::new(); + PATHS.get_or_init(|| { + let mut out = HashMap::new(); + for (name, items) in menus::menu_tree() { + walk(&items, &mut vec![name.to_string()], &mut out); + } + out + }) +} + +/// The menu path of the item of the menu bar that runs command `id` as a key press runs it +/// ([`a_menu_bar_item_runs`]): the first item that runs it with no params, or else the first item, +/// whose params all its items give. +fn menu_bar_path(id: &str) -> Option<&'static str> { + let (_, params) = command_doc(id)?; + let items = menu_bar_paths().get(id).filter(|_| a_menu_bar_item_runs(id, params))?; + chosen_path(items) +} + +/// The menu path [`menu_bar_path`] takes from a command's (menu path, params) items: the first +/// item with no params, or else the first item. +fn chosen_path(items: &[(String, Value)]) -> Option<&str> { + items.iter().find(|(_, p)| is_bare(p)).or(items.first()).map(|(path, _)| path.as_str()) +} + /// Does a command's params doc (`{a?, b?} …`) say it runs with no params, as a shortcut runs it? fn runs_without_params(params: &str) -> bool { params @@ -653,23 +809,35 @@ const NEEDS_A_PARAM: [&str; 20] = [ /// The (label, params) of each menu bar item, by command id. fn menu_bar_items() -> &'static HashMap<&'static str, Vec<(&'static str, Value)>> { + static ITEMS: OnceLock>> = OnceLock::new(); + ITEMS.get_or_init(|| bar_items(&menus::menu_tree())) +} + +/// The (label, params) of each item of the menus `tree`, by command id. Items that list an +/// installed plug-in ([`menus::lists_plugin`]) or show a name that is user data +/// ([`menus::shows_a_name`]: a custom toolbar, a script, a recent file, a saved selection) are left +/// out: [`menu_bar_items`] is built once, and such items come and go after that. +fn bar_items(tree: &[(&'static str, Vec)]) -> HashMap<&'static str, Vec<(&'static str, Value)>> { fn walk(items: &[menus::Item], out: &mut HashMap<&'static str, Vec<(&'static str, Value)>>) { for it in items { match it { - menus::Item::Cmd(label, id, p) => out.entry(*id).or_default().push((*label, p.clone())), + menus::Item::Cmd(label, id, p) if on_the_bar(id, label, p) => out.entry(*id).or_default().push((*label, p.clone())), menus::Item::Sub(_, children) => walk(children, out), _ => {} } } } - static ITEMS: OnceLock>> = OnceLock::new(); - ITEMS.get_or_init(|| { - let mut out = HashMap::new(); - for (_, items) in menus::menu_tree() { - walk(&items, &mut out); - } - out - }) + let mut out = HashMap::new(); + for (_, items) in tree { + walk(items, &mut out); + } + out +} + +/// Is the menu item with `label` that runs command `id` with params `p` one [`bar_items`] reads: it +/// lists no installed plug-in and shows no name that is user data? +fn on_the_bar(id: &str, label: &str, p: &Value) -> bool { + !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) } fn is_bare(p: &Value) -> bool { @@ -686,6 +854,25 @@ pub(crate) fn menu_target(id: &str) -> Option<(String, Value)> { items.iter().all(|(_, q)| q == p).then(|| menus::click_target(label, id, p)) } +/// What a command search pick, or a shortcut without params, of command `id` runs now: `Ok` with +/// what a click on its menu bar item runs ([`menu_target`]), or `None` when no such item gives it +/// params. While that item is dimmed ([`menus::enabled`]), no dialog opens for it: `Err` holds the +/// status line ([`dimmed`]), as for Remove Background… with no image selected. +pub(crate) fn menu_target_now(app: &VectorcraftApp, id: &str) -> Option> { + let target = menu_target(id)?; + Some(if menus::enabled(app, id) { Ok(target) } else { dimmed(app, id) }) +} + +/// What [`menu_target_now`] gives for command `id` while its menu bar item is dimmed: the status +/// line, with the engine's reason when it gives one. The app's own rules ([`menus::enabled`]) dim +/// UI commands, and some commands the engine would run, without giving a reason. +fn dimmed(app: &VectorcraftApp, id: &str) -> Result<(String, Value), String> { + let Some(why) = vectorcraft_engine::find_command(id).and_then(|c| (c.enabled)(&app.session).err()) else { + return Err(format!("command `{id}` is not available right now")); + }; + Err(vectorcraft_engine::EngineError::Disabled(id.to_string(), why).to_string()) +} + /// Is `key` a command with no default shortcut that a key press can't run: no item of the menu /// bar runs it without params or with one set of params ([`menu_target`]), it opens no dialog when /// run without them (as the Variables panel's commands do), and it is a query, whose answer a key @@ -694,17 +881,11 @@ pub(crate) fn menu_target(id: &str) -> Option<(String, Value)> { fn needs_a_value(key: &str) -> bool { static KEYS: OnceLock> = OnceLock::new(); KEYS.get_or_init(|| { - // A menu item that runs the command itself with params of its own counts only when the - // params doc names no required field: Edit › Find Next gives `find` empty, which it rejects. - let runs = |c: &vectorcraft_engine::CommandSpec| { - menu_bar_items().get(c.id).is_some_and(|ps| ps.iter().any(|(_, p)| is_bare(p))) - || menu_target(c.id).is_some_and(|(id, _)| id != c.id || !names_a_required_field(c.params)) - }; vectorcraft_engine::command_specs() .iter() .filter(|c| { c.shortcut.is_none() - && !runs(c) + && !a_menu_bar_item_runs(c.id, c.params) && !crate::dialogs::variables::opens(c.id) && (!c.journal || NEEDS_A_PARAM.contains(&c.id) || (!c.menu.is_empty() && names_a_required_field(c.params))) }) @@ -714,6 +895,31 @@ fn needs_a_value(key: &str) -> bool { .contains(key) } +/// Does an item of the menu bar run command `id`, whose params doc is `params`, as a key press runs +/// it: with no params, or with the one set of params all its items give ([`menu_target`])? A menu +/// item that runs the command itself with params of its own counts only when the params doc names +/// no required field: Edit › Find Next gives `find` empty, which it rejects. +fn a_menu_bar_item_runs(id: &str, params: &str) -> bool { + menu_bar_items().get(id).is_some_and(|ps| ps.iter().any(|(_, p)| is_bare(p))) + || menu_target(id).is_some_and(|(target, _)| target != id || !names_a_required_field(params)) +} + +/// Is engine command `c` listed in Edit › Keyboard Shortcuts whether or not an item of the menu bar +/// runs it? A command with a menu path of its own is. One without (a panel menu item) is when a key +/// alone can run it: it has a default key, or it is an undoable command, not an alias, whose params +/// doc ends each field with `?`. +fn listed_on_its_own(c: &vectorcraft_engine::CommandSpec) -> bool { + !c.menu.is_empty() || c.shortcut.is_some() || (c.journal && !vectorcraft_engine::cmd::is_alias(c.id) && runs_without_params(c.params)) +} + +/// Is UI command `id` (default key `sc`, params doc `params`) listed in Edit › Keyboard Shortcuts +/// whether or not an item of the menu bar runs it? It is when it has a default key or a params doc +/// of `{}` or `{path…}`. Rename has no default key, and a key press names the selection: it needs +/// no `id`. +fn ui_listed_on_its_own(id: &str, sc: &str, params: &str) -> bool { + !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || id == "ui.rename" +} + /// Does a menu bar item run the command with no params, so that it does what its label says and opens /// no dialog? A command no menu bar item runs (the Crop Image item runs `ui.cropImage`), a menu item /// that gives it params, a [`NEEDS_A_PARAM`] command and a field a call must give each rule it out. @@ -863,7 +1069,7 @@ pub fn assign_in(km: &mut Keymap, key: &str, chord: Option<&str>, force: bool) - .iter() .map(|h| { let k = entry_key(&h.command); - entry(&k).map_or(k, |e| e.label.clone()) + entry(&k).map_or(k, |e| english_name(&e)) }) .collect(); Err(format!("{} is already used by {}", menus::pretty_shortcut(&vc_format(&chord)), names.join(", "))) @@ -1295,12 +1501,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(), Cow::Owned(km) => dialog_shortcuts_with(km, &e.key, view.backtick.as_deref()), }; - let matches = e.label.to_lowercase().contains(&q) - || shown_label(e).to_lowercase().contains(&q) - || e.group.to_lowercase().contains(&q) - || shown_group(&e.group).to_lowercase().contains(&q) - || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q)); - if !q.is_empty() && !matches { + if !q.is_empty() && !row_matches(e, &q, sc.as_deref()) { continue; } let row = ui.horizontal(|ui| { @@ -1478,6 +1679,18 @@ fn drop_hidden(d: &mut Dialog, overrides: &Overrides) { } } +/// Does the search text `q` (lowercase) find entry `e`, whose shortcut text is `sc`? It does when +/// `e`'s label or group, in English or as shown, the key it shows ([`Entry::shows_key`]) or its +/// shortcut contains `q`. +fn row_matches(e: &Entry, q: &str, sc: Option<&str>) -> bool { + e.label.to_lowercase().contains(q) + || shown_label(e).to_lowercase().contains(q) + || e.group.to_lowercase().contains(q) + || shown_group(&e.group).to_lowercase().contains(q) + || (e.shows_key && e.key.to_lowercase().contains(q)) + || sc.is_some_and(|s| s.to_lowercase().contains(q)) +} + /// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts. fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { d.fields.insert("__recording".into(), json!("")); @@ -1485,7 +1698,7 @@ fn record(d: &mut Dialog, km: &mut Keymap, key: &str, chord: &str) { Ok(removed) => { d.fields.insert("set".into(), json!(CUSTOM)); if let Some(first) = removed.first() { - let names: Vec = removed.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect(); + let names: Vec = removed.iter().map(|k| entry(k).map(|e| shown_name(&e)).unwrap_or(k.clone())).collect(); d.fields.insert( "__message".into(), json!(crate::i18n::fmt( @@ -1819,7 +2032,7 @@ mod tests { /// An Export Text line: the row's name as the dialog shows it, a tab and `shortcut`. fn text_row(key: &str, shortcut: &str) -> String { let e = entry(key).unwrap_or_else(|| panic!("no entry {key}")); - if e.is_tool || e.group.is_empty() { format!("{}\t{shortcut}", e.label) } else { format!("{} › {}\t{shortcut}", e.group, e.label) } + format!("{}\t{shortcut}", english_name(&e)) } /// Export Text writes the set's name, then the Tools and the Menu Commands rows in the dialog's @@ -1832,11 +2045,7 @@ mod tests { let text = export_text("VectorCraft Defaults", &ov); let km = keymap_with(&ov); let names = |tools: bool| -> Vec { - entries() - .iter() - .filter(|e| e.is_tool == tools && listed(km.overrides(), e)) - .map(|e| if e.is_tool || e.group.is_empty() { format!("{}\t", e.label) } else { format!("{} › {}\t", e.group, e.label) }) - .collect() + entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| format!("{}\t", english_name(e))).collect() }; let rows = (names(true), names(false)); (text, km, rows.0, rows.1) @@ -2057,7 +2266,7 @@ mod tests { return; } for (alias, edit) in [("F2", "edit.cut"), ("F3", "edit.copy"), ("F4", "edit.paste")] { - let label = entry(edit).map(|e| e.label.clone()).unwrap(); + let label = entry(edit).map(|e| english_name(&e)).unwrap(); assert_eq!(conflicts_with(&Overrides::new(), alias, "tool:pen"), vec![edit.to_string()], "{alias}"); let mut ov = Overrides::new(); let err = assign(&mut ov, "tool:pen", Some(alias), false).unwrap_err(); @@ -2270,6 +2479,47 @@ mod tests { assert_eq!(menu_target("edit.undo"), None, "runs without params"); } + /// The menu bar items a shortcut or a command search pick reads leave out items that list an + /// installed plug-in (a File › Open With importer, an Object › Plug-ins filter) or show a name + /// that is user data (a custom toolbar, a script of the Scripts folder), which come and go after + /// the items are read; the other items stay. + #[test] + fn menu_bar_items_leave_out_installed_plug_ins_and_user_data() { + let tree = vec![( + "File", + vec![ + menus::Item::Sub("Open With", vec![menus::Item::Cmd("Spins…", "file.openWith", json!({"importer": "plugin.test.spins"}))]), + menus::Item::Cmd("Filter…", "plugin.dialog", json!({"id": "test.filter"})), + menus::Item::Cmd("Mine", "window.toolbar.custom", json!({"name": "Mine"})), + menus::Item::Cmd("tidy.js", crate::scripts_menu::COMMAND, json!({"path": "/scripts/tidy.js"})), + menus::Item::Cmd("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true})), + ], + )]; + let items = bar_items(&tree); + assert!(!items.contains_key("file.openWith"), "{items:?}"); + assert!(!items.contains_key("plugin.dialog"), "{items:?}"); + assert!(!items.contains_key("window.toolbar.custom"), "{items:?}"); + assert!(!items.contains_key(crate::scripts_menu::COMMAND), "{items:?}"); + assert_eq!(items.get("object.path.cleanUp"), Some(&vec![("Clean Up…", json!({"strayPoints": true}))])); + } + + /// While a menu item is dimmed, a pick or a shortcut gets a status line and no target: with the + /// engine's reason when it gives one, and without one when the app's own rules dim the item (an + /// Appearance command with nothing targeted, a UI command). + #[test] + fn a_dimmed_item_gives_a_status_line_with_or_without_a_reason() { + use vectorcraft_testkit::fixtures::Fixture; + let none = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + assert!(!menus::enabled(&none, "edit.findReplace")); + assert_eq!(dimmed(&none, "edit.findReplace"), Err("command `edit.findReplace` is not available right now: no document open".into())); + let empty = VectorcraftApp::new(Fixture::Empty.session(), crate::Services::default()); + for id in ["appearance.addFill", "ui.cropImage"] { + assert!(!menus::enabled(&empty, id), "{id}"); + assert!(vectorcraft_engine::find_command(id).is_none_or(|c| (c.enabled)(&empty.session).is_ok()), "{id}: the engine gives no reason"); + assert_eq!(dimmed(&empty, id), Err(format!("command `{id}` is not available right now"))); + } + } + /// Every command the editor lists, run as a key press runs it on a document with one or with /// several objects selected, reports no missing param. #[test] @@ -2310,6 +2560,175 @@ mod tests { assert_eq!(effective_in(&ov, "object.isolate").as_deref(), Some("Cmd+Alt+Shift+F11")); } + /// Every engine or UI command that an item of the menu bar runs as a key press runs it (without + /// params, or with the one set of params all its items give) is listed in Edit › Keyboard + /// Shortcuts, also when the command has no menu path of its own: Set Corners…, whose params doc + /// names a field a call must give, the Perspective Grid toggles, Edit Custom Dictionary…, a + /// query, and Recolor Artwork…, a UI command. Such a command is listed in the group of its id, + /// next to the commands of its family that were listed before. A shortcut given to one survives + /// a saved set's round trip. Find Next is left out: its menu item gives `find` empty, which the + /// command rejects. When another entry has the same group and label, the command the menu item + /// runs keeps the name if no menu item runs the other: File › Export Selection… is + /// `file.exportSelection`, and `document.exportSelection` moves to Document. Otherwise the + /// command is left out: Window › Learn is the Learn panel's entry, not `window.learn`. + #[test] + fn every_command_the_menu_bar_runs_can_be_given_a_shortcut() { + let none = Overrides::new(); + let mut missing: Vec<&str> = menu_bar_items() + .iter() + .filter(|(id, items)| items.iter().any(|(_, p)| is_bare(p)) || menu_target(id).is_some()) + .map(|(id, _)| *id) + .filter(|id| *id != "edit.findNext" && *id != "window.learn") + .filter(|id| vectorcraft_engine::find_command(id).is_some() || menus::UI_COMMANDS.iter().any(|c| c.0 == *id)) + .filter(|id| !entry(id).is_some_and(|e| listed(&none, &e))) + .collect(); + missing.sort_unstable(); + assert!(missing.is_empty(), "{missing:?}"); + assert!(entry("window.learn").is_none(), "Window › Learn is the Learn panel's entry"); + let shown = |id: &str| entry(id).map(|e| (e.group, e.label)); + assert_eq!(shown("file.exportSelection"), Some(("File".into(), "Export Selection…".into()))); + assert_eq!(shown("document.exportSelection"), Some(("Document".into(), "Export Selection…".into()))); + // No command listed only because an item of the menu bar runs it shares its group and label + // with another entry. + let own = |k: &str| { + vectorcraft_engine::command_specs() + .iter() + .find(|c| c.id == k) + .map(listed_on_its_own) + .or_else(|| menus::UI_COMMANDS.iter().find(|c| c.0 == k).map(|(id, _, sc, p)| ui_listed_on_its_own(id, sc, p))) + .unwrap_or(true) + }; + let (v, tail) = fixed_entries(); + let mut names: HashMap<(&str, &str), Vec<&str>> = HashMap::new(); + for e in v.iter().chain(tail) { + names.entry((e.group.as_str(), e.label.as_str())).or_default().push(e.key.as_str()); + } + let shared: Vec<_> = names.iter().filter(|(_, keys)| keys.len() > 1 && keys.iter().any(|k| !own(k))).collect(); + assert!(shared.is_empty(), "{shared:?}"); + for (id, group) in [ + (crate::addons::pathtools::set_corners::COMMAND, "Addon"), + ("perspective.grid.snap", "Perspective"), + (crate::dialogs::custom_dictionary::COMMAND, "Text"), + ("ui.recolorDialog", "Ui"), + ] { + let e = entry(id).unwrap_or_else(|| panic!("{id} is listed")); + assert_eq!(e.group, group, "{id}"); + } + for id in + [crate::addons::pathtools::set_corners::COMMAND, "perspective.grid.lock", crate::dialogs::custom_dictionary::COMMAND, "ui.recolorDialog"] + { + let mut ov = Overrides::new(); + assign(&mut ov, id, Some("Cmd+Alt+Shift+F11"), false).unwrap(); + let (_, back) = import_json(&export_json("Mine", &ov)).unwrap(); + assert_eq!(effective_in(&back, id).as_deref(), Some("Cmd+Alt+Shift+F11"), "{id}"); + } + } + + /// No two entries of Keyboard Shortcuts have the same English name. A command that an item of + /// the menu bar runs takes that item's menu path (Window › Swatch Libraries › Other Library…), a + /// command whose id extends another command's id takes that command's label (Character Style + /// Options › Reset Panel), and the other entries that would share a name show their key, except + /// the one an item of the menu bar runs: File › New… is the New Document dialog's entry, and + /// `file.new` is File › New… (file.new). An entry whose name no other entry has keeps its group. + /// The search finds an entry by the key it shows, and a conflict, in `shortcuts.set` and in the + /// dialog, names the entry that has the shortcut by its whole name. + #[test] + fn no_two_entries_have_the_same_name() { + let all = entries(); + let mut by_name: HashMap> = HashMap::new(); + for e in all.iter() { + by_name.entry(english_name(e)).or_default().push(e.key.as_str()); + } + let shared: Vec<_> = by_name.into_iter().filter(|(_, keys)| keys.len() > 1).collect(); + assert!(shared.is_empty(), "{shared:?}"); + let shown = |id: &str| entry(id).map(|e| (e.group, e.label, e.shows_key)); + for (id, group) in [ + ("window.brushLibrary.other", "Window › Brush Libraries"), + ("window.graphicStyleLibrary.other", "Window › Graphic Style Libraries"), + ("window.swatchLibrary.other", "Window › Swatch Libraries"), + ("window.symbolLibrary.other", "Window › Symbol Libraries"), + ] { + assert_eq!(shown(id), Some((group.into(), "Other Library…".into(), false)), "{id}"); + } + assert_eq!(shown("ui.charStyleOptions.resetPanel"), Some(("Character Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("ui.paraStyleOptions.resetPanel"), Some(("Paragraph Style Options".into(), "Reset Panel".into(), false))); + assert_eq!(shown("file.newDialog"), Some(("File".into(), "New…".into(), false))); + assert_eq!(shown("file.new"), Some(("File".into(), "New…".into(), true))); + assert_eq!(shown("text.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), false))); + assert_eq!(shown("type.fitHeadline"), Some(("Type".into(), "Fit Headline".into(), true))); + for id in ["file.exportInBackground", "file.openInBackground"] { + assert_eq!(shown(id), Some(("File".into(), "Continue in Background".into(), true)), "{id}"); + } + assert_eq!(shown("file.cancelExport"), Some(("File".into(), "Cancel Export".into(), false))); + let e = entry("type.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline (type.fitHeadline)"), "{}", shown_name(&e)); + assert!(row_matches(&e, "type.fitheadline", None)); + let e = entry("text.fitHeadline").unwrap(); + assert!(shown_name(&e).ends_with("Fit Headline"), "{}", shown_name(&e)); + assert!(!row_matches(&e, "text.fitheadline", None)); + let mut ov = Overrides::new(); + assign(&mut ov, "window.swatchLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap(); + let err = assign(&mut ov, "window.brushLibrary.other", Some("Cmd+Alt+Shift+F11"), false).unwrap_err(); + assert!(err.ends_with("is already used by Window › Swatch Libraries › Other Library…"), "{err}"); + assign(&mut ov, "type.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap(); + let err = assign(&mut ov, "text.fitHeadline", Some("Cmd+Alt+Shift+F10"), false).unwrap_err(); + assert!(err.ends_with("is already used by Type › Fit Headline (type.fitHeadline)"), "{err}"); + let mut d = Dialog::new("shortcuts", json!({})); + let mut km = keymap_with(&Overrides::new()); + record(&mut d, &mut km, "type.fitHeadline", "Cmd+Alt+Shift+F10"); + record(&mut d, &mut km, "text.fitHeadline", "Cmd+Alt+Shift+F10"); + assert!(d.str("__message").contains("was already used by Type › Fit Headline (type.fitHeadline) "), "{}", d.str("__message")); + } + + /// Two filter plug-ins with the same name in the same menu show their ids, and a third whose + /// name is one of those names with the id stays apart from it. A filter with that name in + /// another menu, or with a label that differs by its "…", shows no id. + #[test] + fn a_plugin_named_like_another_row_with_its_key_stays_apart() { + let row = |key: &str, label: &str, group: &str| Entry { + key: key.into(), + label: label.into(), + group: group.into(), + is_tool: false, + shows_key: false, + }; + let menu = "Object › Plug-ins"; + let mut rows = vec![ + row("plugin:a", "Foo", menu), + row("plugin:b", "Foo", menu), + row("plugin:c", "Foo (plugin:a)", menu), + row("plugin:d", "Bar", menu), + row("plugin:e", "Foo", "Object › Plug-ins › Other"), + row("plugin:f", "Foo…", menu), + ]; + distinct_names(&mut rows); + let names: Vec = rows.iter().map(english_name).collect(); + assert_eq!( + names, + [ + "Object › Plug-ins › Foo (plugin:a)", + "Object › Plug-ins › Foo (plugin:b)", + "Object › Plug-ins › Foo (plugin:a) (plugin:c)", + "Object › Plug-ins › Bar", + "Object › Plug-ins › Other › Foo", + "Object › Plug-ins › Foo…" + ] + ); + } + + /// A command with items in more than one menu is shown under the path of its first item with + /// no params, or else of its first item: Recolor Artwork… under Edit › Edit Colors, not Edit › + /// Edit Colors › Recolor with Preset. + #[test] + fn a_command_on_several_items_takes_the_path_of_its_item_with_no_params() { + let item = |path: &str, p: Value| (path.to_string(), p); + let items = [item("A › B", json!({"x": 1})), item("A › C", Value::Null), item("A › D", json!({}))]; + assert_eq!(chosen_path(&items), Some("A › C")); + assert_eq!(chosen_path(&[item("A › B", json!({"x": 1})), item("A › C", json!({"x": 2}))]), Some("A › B")); + assert_eq!(chosen_path(&[]), None); + assert_eq!(menu_bar_path("ui.recolorDialog"), Some("Edit › Edit Colors")); + } + /// `shortcuts.set`, `shortcuts.list` and `shortcuts.preset` reject a supplied param of the wrong /// type with an error naming it, and change nothing; an omitted or null param keeps its default. #[test] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 83d53d700c..db262870ab 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -787,8 +787,20 @@ fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) // The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)… let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]; for (k, tk) in arrows { + // The press's Shift and Alt (the consume below takes those presses too) go to a tool that + // claims the arrow (Shift steps a blend widget by ten); other tools get the plain arrow. + let pressed = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k && !(modifiers.command || modifiers.ctrl) => Some(*modifiers), + _ => None, + }) + }); if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); + let mods = match pressed { + Some(m) if app.session.tool_claims_key(tk, view) => crate::canvas::mods(m, false), + _ => Mods::default(), + }; + let _ = app.session.tool_key(tk, mods, view); } } // Digits (5 while dragging with the Perspective Selection tool), once per press. @@ -985,9 +997,10 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H } /// What a shortcut without params runs when its command's menu bar item gives params -/// ([`crate::shortcut_editor::menu_target`]): what a click on that item runs. -fn menu_target(c: &CommandRef) -> Option<(String, serde_json::Value)> { - c.params.as_object().is_none_or(serde_json::Map::is_empty).then(|| crate::shortcut_editor::menu_target(&c.id)).flatten() +/// ([`crate::shortcut_editor::menu_target_now`]): what a click on that item runs, or while the item +/// is dimmed, why the command can't run. +fn menu_target(app: &VectorcraftApp, c: &CommandRef) -> Option> { + c.params.as_object().is_none_or(serde_json::Map::is_empty).then(|| crate::shortcut_editor::menu_target_now(app, &c.id)).flatten() } /// Params used when a shortcut invokes its command. The named panel function keys retain their @@ -1030,8 +1043,11 @@ fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { // every artboard instead of the art. crate::panels::artboards::select_all(app); } else { - let (id, p) = menu_target(c).unwrap_or_else(|| (c.id.clone(), shortcut_params(c))); - crate::menus::invoke(app, &id, p); + match menu_target(app, c) { + Some(Ok((id, p))) => crate::menus::invoke(app, &id, p), + Some(Err(why)) => app.status(why), + None => crate::menus::invoke(app, &c.id, shortcut_params(c)), + } } } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 8d32a4de22..60b8c5eacd 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -294,9 +294,13 @@ fn a_shortcut_runs_what_its_menu_item_runs() { /// Commands whose menu item opens a slider or a dialog before anything changes: the sliders of /// Smooth and Simplify, Offset Path, the Object › Transform dialogs, Average, Split Into Grid, -/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into -/// Dashes, Find Nearest Swatch, and four "…" items whose menu entry gives params. -const MENU_DIALOG_COMMANDS: [&str; 22] = [ +/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, the Object › Plug-ins dialogs +/// (Arrange in Grid, Replace with Top Object, the size, opacity and sort dialogs of the Arrange +/// add-on, Set Corners…, Divide into Dashes, Find Nearest Swatch, the swatch legend, the type +/// tools, the select add-on and Select by Link Status…), four "…" items whose menu entry gives +/// params, Edit Custom Dictionary…, Composite Fonts…, Recolor Artwork…, the Edit menu's preset +/// dialogs and Apply SVG Filter…. +const MENU_DIALOG_COMMANDS: [&str; 39] = [ "object.path.smooth", "object.path.simplify", "object.path.offsetPath", @@ -313,12 +317,29 @@ const MENU_DIALOG_COMMANDS: [&str; 22] = [ "artboard.rearrange", "object.pathfinder.options", "addon.arrange.grid", + "addon.arrange.replace", + "addon.arrange.size", + "addon.arrange.randomizeOpacity", + "addon.arrange.sort", + crate::addons::pathtools::set_corners::COMMAND, crate::addons::pathtools::dashes::COMMAND, crate::addons::color::nearest::COMMAND, + crate::addons::color::legend::COMMAND, + crate::addons::typetools::COMMAND, + crate::addons::select::COMMAND, + crate::addons::select::link_status::COMMAND, "object.path.cleanUp", "object.mesh.create", "type.smartPunctuation", "edit.findReplace", + crate::dialogs::custom_dictionary::COMMAND, + crate::dialogs::composite_fonts::COMMAND, + "ui.recolorDialog", + "ui.flattenerPresetsDialog", + "ui.printPresetsDialog", + "ui.pdfPresetsDialog", + "ui.perspectivePresetsDialog", + "ui.svgFilterDialog", ]; /// F pressed without a modifier. @@ -342,8 +363,33 @@ fn undo_len(app: &VectorcraftApp) -> usize { app.session.active().unwrap().history.undo.len() } +/// View › Perspective Grid › Snap to Grid, whose command has no menu path of its own, turns off and +/// on again from a shortcut given to it, as its menu item does. +#[test] +fn a_shortcut_on_a_perspective_grid_toggle_runs_it() { + let snaps = |app: &VectorcraftApp| crate::menus::checked(app, "perspective.grid.snap", &serde_json::Value::Null); + let mut app = app_with_shortcut("perspective.grid.snap", "F"); + assert_eq!(snaps(&app), Some(true), "on by default"); + frame(&mut app, press_f()); + assert_eq!(snaps(&app), Some(false), "F turns it off: {}", app.ui.status); + frame(&mut app, vec![]); + frame(&mut app, press_f()); + assert_eq!(snaps(&app), Some(true), "and on again: {}", app.ui.status); +} + +/// A shortcut on File › Export Selection… does what a click on the menu item does: it adds the +/// selection to Export for Screens' assets and opens Export for Screens. +#[test] +fn a_shortcut_on_export_selection_opens_export_for_screens() { + let mut app = app_with_shortcut("file.exportSelection", "F"); + frame(&mut app, press_f()); + assert_eq!(open_dialog(&app).as_deref(), Some("exportForScreens"), "{}", app.ui.status); +} + /// A command given a key without a modifier runs what its menu item runs, as it does on a chord: -/// its slider or dialog opens and nothing changes until it is confirmed. +/// its slider or dialog opens and nothing changes until it is confirmed. The dialog is the one a +/// click on the command's menu item opens (its item without params, else its first item), also for +/// commands without a menu path of their own (Set Corners…, Recolor Artwork…). #[test] fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { let chord = vec![ @@ -364,8 +410,17 @@ fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { }; let by_key = opened("F", press_f()); let by_chord = opened("Cmd+Alt+Shift+F9", chord.clone()); - if by_key.is_none() || by_key != by_chord { - failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}")); + let mut app = app_with_a_selected_rectangle(); + // Type › Composite Fonts… shows while East Asian options are shown. + app.session.prefs.show_east_asian_options = true; + let items: Vec<_> = crate::menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some(id)).collect(); + let item = items.iter().find(|e| e.params.as_object().is_none_or(|o| o.is_empty())).or(items.first()); + // Pathfinder Options… is an item of the Pathfinder panel's menu, which runs it without params. + let (menu_id, p) = item.map_or_else(|| (id.to_string(), json!({})), |item| crate::menus::click_target(&item.label, id, &item.params)); + crate::menus::invoke(&mut app, &menu_id, p); + let by_menu = app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.get("__command").cloned())); + if by_key.is_none() || by_key != by_chord || by_chord != by_menu { + failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}, the menu item {by_menu:?}")); } } assert!(failures.is_empty(), "{failures:#?}"); @@ -412,6 +467,38 @@ fn a_single_key_shortcut_without_a_dialog_runs_at_once() { assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "Shift+X swaps fill and stroke: {}", app.ui.status); } +/// A key or a chord of a command whose menu item is dimmed opens no dialog: Remove Background… and +/// Object Mosaic… with a rectangle selected change nothing and say why they can't run, and so does +/// Find and Replace… with no document open. +#[test] +fn a_shortcut_of_a_dimmed_command_opens_no_dialog_and_says_why() { + let chord = vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ]; + for id in ["addon.image.removeBackground", "object.createObjectMosaic", "edit.findReplace"] { + assert!(crate::shortcut_editor::menu_target(id).is_some(), "{id} has a dialog"); + for (shortcut, events) in [("F", press_f()), ("Cmd+Alt+Shift+F9", chord.clone())] { + let mut app = if id == "edit.findReplace" { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, id, shortcut); + app.ui.status.clear(); + app + } else { + app_with_shortcut(id, shortcut) + }; + assert!(!crate::menus::enabled(&app, id), "{id}"); + let before = app.session.active().map(|st| st.history.undo.len()); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + let after = app.session.active().map(|st| st.history.undo.len()); + assert_eq!((open_dialog(&app), after), (None, before), "{id} on {shortcut}: {}", app.ui.status); + assert!(app.ui.status.contains("not available"), "{id} on {shortcut}: {}", app.ui.status); + } + } +} + /// With no document open, a command's key without a modifier reports what the same command /// reports on a chord. #[test] @@ -1616,6 +1703,29 @@ fn the_command_search_that_opens_while_enter_is_down_waits_for_a_new_press() { assert!(!app.ui.palette_open, "a new press runs the highlighted result"); } +/// Enter on a command search result whose menu item opens a dialog (Create Gradient Mesh…) opens +/// that dialog: the press and its repeats do not confirm it, and a new press does. +#[test] +fn enter_on_a_command_search_result_opens_its_dialog_and_a_new_press_confirms_it() { + let mut app = app_with_a_selected_rectangle(); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + app.select_tool("selection"); + app.ui.palette_open = true; + app.ui.palette_query = "object.mesh.create".into(); + let before = undo_len(&app); + for repeat in 0..4 { + held.press(&mut app); + let command = app.ui.dialog.as_ref().and_then(|d| d.fields.get("__command").cloned()); + assert_eq!(command, Some(json!("object.mesh.create")), "repeat {repeat}: its dialog stays open: {}", app.ui.status); + assert_eq!(undo_len(&app), before, "repeat {repeat}: nothing changed"); + } + assert!(!app.ui.palette_open); + held.release(&mut app); + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "a new press confirms"); + assert_eq!(undo_len(&app), before + 1, "the mesh is one undo step: {}", app.ui.status); +} + /// A field that rejects its text on Enter keeps the dialog open, while the key is held too. #[test] fn holding_enter_on_a_rejected_value_leaves_the_dialog_open() { @@ -2872,8 +2982,13 @@ fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() { fn a_filter_plugin_row_translates_its_path_and_not_its_name() { let zh = crate::i18n::Lang::from_code("zh-hant").unwrap(); crate::i18n::set_current(zh); - let entry = - |key: &str| crate::shortcut_editor::Entry { key: key.into(), label: "Group".into(), group: "Object › Plug-ins".into(), is_tool: false }; + let entry = |key: &str| crate::shortcut_editor::Entry { + key: key.into(), + label: "Group".into(), + group: "Object › Plug-ins".into(), + is_tool: false, + shows_key: false, + }; let path = format!("{} › {}", crate::i18n::tr(zh, "Object"), crate::i18n::tr(zh, "Plug-ins")); let (filter, command) = (crate::shortcut_editor::shown_name(&entry("plugin:a.b")), crate::shortcut_editor::shown_name(&entry("object.group"))); crate::i18n::set_current(crate::i18n::Lang::EN); @@ -2922,6 +3037,38 @@ fn a_plain_up_nudges_unless_a_corner_widget_is_pressed() { assert_eq!(kind2, CornerKind::InvertedRound, "Up stepped the kind"); } +/// While a blend widget is pressed, an arrow steps its value by one and Shift with an arrow by ten. +#[test] +fn shift_arrows_step_a_pressed_blend_widget_by_ten() { + use vectorcraft_doc::{NodeId, NodeKind}; + use vectorcraft_tools::blendwidget::BlendWidget; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 190, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 90.0), rect(&mut app, 290.0)); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let id = NodeId(app.session.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let shift = |app: &VectorcraftApp| { + let NodeKind::Blend { spec, .. } = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("not a blend") }; + spec.color_shift + }; + let view = app.view_info(); + let st = app.session.active().unwrap(); + let at = BlendWidget::of(&st.doc, &st.selection, view.zoom, vectorcraft_geom::Vec2::new(0.0, -1.0))[0].at; + app.session.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), view).unwrap(); + frame(&mut app, press(Key::ArrowUp, Modifiers::SHIFT)); + assert_eq!(shift(&app), 10.0, "Shift+Up steps by ten"); + frame(&mut app, press(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(shift(&app), 11.0, "Up steps by one"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y), view).unwrap(); + assert_eq!(shift(&app), 11.0); +} + /// #5218: the status bar navigator's four buttons are commands of their own, listed in Edit › /// Keyboard Shortcuts without a default key, so a user can give them one (Page Up and Page Down /// here) and step through the artboards from the keyboard. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 14d651564f..48635562e7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -9,7 +9,9 @@ use crate::theme::Brightness; /// Per-document view (camera). #[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct View { - /// Screen points per document point (1.0 = 100%). + /// The zoom (1.0 = 100%): points of the screen per document point, counted before + /// Preferences › User Interface › UI Scaling, which scales the interface and not the art. + /// [`Self::screen_zoom`] is the zoom in the interface's points. pub zoom: f64, /// Document point at the centre of the canvas. pub center: Point, @@ -55,6 +57,19 @@ impl View { outline: false, }) } + + /// The interface's points per document point when the interface is zoomed by `ui_zoom` (egui's + /// zoom factor, which Preferences › User Interface › UI Scaling sets). A document point then + /// covers the same device pixels at every UI Scaling. + pub fn screen_zoom(&self, ui_zoom: f32) -> f64 { + self.zoom / f64::from(ui_zoom) + } + + /// This view with its zoom in the interface's points ([`Self::screen_zoom`]), as the canvas + /// renders it. + pub(crate) fn on_screen(&self, ui_zoom: f32) -> Self { + Self { zoom: self.screen_zoom(ui_zoom), ..*self } + } } /// The status bar's angle menu and View › Rotate View list these preset view angles, each as @@ -405,6 +420,10 @@ pub struct UiState { /// The Learn panel's tab, skill and tutorial, kept while the app runs. #[serde(skip)] pub learn: crate::panels::learn::View, + /// The tool whose place a Learn tutorial step's link shows (`learn.locateTool`), while its mark + /// is on screen. Not saved. + #[serde(skip)] + pub learn_locate: Option, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -590,6 +609,16 @@ pub struct UiState { /// adds a new action, as after Begin Recording. #[serde(skip)] pub recording_into: 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. + #[serde(skip)] + pub recording_base: Option, + /// The bytes that the steps recorded so far add to that file, at most, as last counted: (the + /// journal's rewrites, the recording's first entry, the journal's length, the bytes) + /// ([`vectorcraft_engine::Journal::rewrites`]). + #[serde(skip)] + pub recording_tail: Option<(u64, usize, usize, 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, @@ -1040,6 +1069,7 @@ impl Default for UiState { color_settings_scroll: false, find_font_in: String::new(), learn: Default::default(), + learn_locate: None, control_bar: false, control_bar_bottom: false, control_bar_hidden_groups: Default::default(), @@ -1098,6 +1128,8 @@ impl Default for UiState { action_sets_selected: vec![], recording: None, recording_into: None, + recording_base: None, + recording_tail: None, action_playback: Default::default(), action_playing: None, shortcut_overrides: Default::default(), diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 3a84e0b94d..d7f438d285 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -10,6 +10,7 @@ use vectorcraft_engine::file_access::AutomationRoots; use crate::background::SharedWriteFn; use crate::control::ControlRequest; +use crate::state::Dialog; use crate::{Services, VectorcraftApp}; /// A folder holding `in` (the read root, with `a.svg`), `out` (the write root) and `outside` @@ -128,6 +129,45 @@ fn control_saves_leave_out_the_documents_file_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// In one session with a library loaded from outside the read root: the library keeps its file-name +/// id, a reply that names its path shows the file name, a save without a path leaves out the +/// document's file, and a save or export to a path the client gave in the write root keeps that path +/// in `path` and `files`, also in a `command.batch` step's result. A refused path outside the write root is still shown by its file name. +#[test] +fn control_saves_keep_the_given_path_beside_a_hidden_library() { + let (mut app, tx, base) = confined("save-and-library"); + let library = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{library}"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let own = s(base.join("out/doc.vectorcraft")); + app.run("file.saveAs", json!({"path": own})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = request(&mut app, &tx, "app.save", json!({})); + assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{r}"); + let r = request(&mut app, &tx, "app.save", json!({"path": own})); + assert_eq!(r["result"]["path"], json!(own), "{r}"); + let split = s(base.join("out/split.vectorcraft")); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.save", "params": {"path": split, "separateArtboards": true}})); + assert_eq!(r["result"]["path"], json!(split), "{r}"); + assert_eq!(r["result"]["files"][0], json!(split), "{r}"); + let batched = s(base.join("out/batched.vectorcraft")); + let steps = json!({"commands": [{"command": "document.save", "params": {"path": batched}}]}); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "command.batch", "params": steps})); + assert_eq!(r["result"]["results"][0]["path"], json!(batched), "{r}"); + let svg = s(base.join("out/a.svg")); + let r = request(&mut app, &tx, "app.export", json!({"path": svg, "wait": true})); + assert_eq!(r["result"]["path"], json!(svg), "{r}"); + app.ui.status = format!("from {library}"); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from b.svg", "{r}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + let refused = s(base.join("outside/c.vectorcraft")); + let r = request(&mut app, &tx, "app.save", json!({"path": refused, "wait": true})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root") && !e.contains(&refused)), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. @@ -446,6 +486,366 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before +/// it is a relative path, outside the root, and is left out. +#[test] +fn ui_inspect_checks_a_folder_preference_as_written() { + let (mut app, tx, base) = confined("inspect-folder-space"); + let inside = s(base.join("in")); + app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); + assert!(!prefs.contains_key("templatesFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working +/// folder as the read root, a path outside it with a space before it reads as a name in the working +/// folder, and is still left out, because the app trims it and uses the path outside. +#[test] +fn ui_inspect_checks_a_folder_preference_as_trimmed() { + let (_, _, base) = confined("inspect-folder-trimmed"); + let cwd = std::env::current_dir().unwrap(); + let roots = AutomationRoots::new(&[&cwd], &[&base.join("out")]).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx).with_automation_roots(roots); + let outside = s(base.join("outside")); + // Without its drive, the path starts with a separator: trimmed, it is absolute on any platform. + let rooted = match outside.as_bytes() { + [drive, b':', ..] if drive.is_ascii_alphabetic() => &outside[2..], + _ => outside.as_str(), + }; + let spaced = format!(" {rooted}"); + app.ui.engine_prefs = json!({"fontsFolder": spaced, "pluginsFolder": ""}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("pluginsFolder").and_then(Value::as_str), Some(""), "{r}"); + assert!(!prefs.contains_key("fontsFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the +/// command palette query and a script's report show the id and file name shown, and the path appears +/// nowhere in the reply. +#[test] +fn ui_inspect_hides_a_hidden_library_path_in_every_string() { + let (mut app, tx, base) = confined("inspect-status-id"); + let path = s(base.join("outside/b.svg")); + let theirs = format!("loaded/{path}"); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak"); + app.ui.palette_query = format!("\"{theirs}\""); + let source = format!("vc.log({})", json!(format!("read {path}."))); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that +/// library's id wherever the reply holds it, also between spaces and brackets and where a character +/// that continues a path follows it. +#[test] +fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() { + let (mut app, tx, base) = confined("named-hidden-reply"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old"); + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"})); + assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id, +/// and its path appears nowhere in the reply. +#[test] +fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() { + let (mut app, tx, base) = confined("named-hidden-error"); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg"))); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path +/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside +/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays. +#[test] +fn ui_inspect_leaves_out_recorded_paths_under_any_key() { + let (mut app, tx, base) = confined("inspect-action-keys"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) }; + let (url, url_in) = (to_url(&b), to_url(&a)); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["document.compare", {"diffPath": b, "target": a}], + ["file.open", {"file": b}], + ["type.fonts", {"fontFiles": [a, b, url_in]}], + ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}], + ["x.z", {"list": [b, "keep"]}], + ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}], + ["x.v", {"file": url_in}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["document.compare", {"target": a}], + ["file.open", {}], + ["type.fonts", {"fontFiles": [a, url_in]}], + ["x.y", {"other": "keep"}], + ["x.z", {"list": ["keep"]}], + ["x.w", {"other": "keep"}], + ["x.v", {"file": url_in}], + ]); + assert_eq!(steps, &want, "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action +/// (its `library` and `panel` params) and in the action's name, also when nothing else names it. +#[test] +fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-action-steps"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": theirs, "steps": [ + ["swatch.library.add", {"library": theirs}], + ["window.panel", {"panel": format!("library:swatches:{theirs}")}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; + assert_eq!(action["name"], "loaded/b.svg", "{r}"); + assert_eq!( + action["steps"], + json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), + "{r}" + ); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending +/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones +/// inside it. +#[test] +fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-action-paths"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["file.open", {"path": a}], + ["file.open", {"path": b}], + ["file.place", {"paths": [a, b]}], + ["batch.run", {"folder": s(base.join("outside"))}], + ["action.batch", {"destinationFolder": s(base.join("outside"))}], + ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["file.open", {"path": a}], + ["file.open", {}], + ["file.place", {"paths": [a]}], + ["batch.run", {}], + ["action.batch", {}], + ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], + ]); + assert_eq!(steps, &want, "{r}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with +/// either separator, also one saved on another platform. +#[test] +fn ui_inspect_hides_a_library_path_with_either_separator() { + let (mut app, tx, base) = confined("inspect-separators"); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library does not take the id of a library loaded without a path under the same file +/// name: it shows as `loaded/ (2)`, and a request that names the other library reaches it. +#[test] +fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { + let (mut app, tx, base) = confined("inspect-loaded-id"); + let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); + let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); + assert_eq!(loaded["library"], "loaded/b.svg"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// Hidden libraries with the same file name that are shown for the first time in the same reply are +/// numbered in the order of their paths, as the library commands number them. +#[test] +fn hidden_libraries_are_numbered_in_the_order_of_their_paths() { + let (mut app, tx, base) = confined("hidden-load-order"); + let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; + for dir in ["z", "a"] { + std::fs::create_dir_all(base.join("outside").join(dir)).unwrap(); + let path = base.join("outside").join(dir).join("b.gpl"); + std::fs::write(&path, gpl).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); + } + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("z").join("b.gpl"))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library keeps the id a reply showed for it when another library with the same file name is +/// loaded later. +#[test] +fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() { + let (mut app, tx, base) = confined("hidden-stable-id"); + let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + std::fs::create_dir_all(base.join("outside").join("z")).unwrap(); + let path = base.join("outside").join("z").join("b.gpl"); + std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + app.ui.color_guide_limit = format!("loaded/{}", s(path)); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error from a panic hides a hidden library's path, also when the request stopped referring to +/// the library before it panicked. +#[test] +fn a_panic_reply_hides_a_library_hidden_before_the_request() { + let (mut app, tx, base) = confined("panic-hidden"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let r = request(&mut app, &tx, "test.panic", json!({"forget": true})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error shows a path outside the read root that the request sent by its file name, whether or not +/// a hidden library was loaded from that path, so that it reads the same either way. A path the +/// request sends does not show the app's own text that holds it, in an error or a result, and a result +/// shows that path by its file name as an error does. +#[test] +fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { + let (mut app, tx, base) = confined("client-echo"); + let path = s(base.join("outside/b.svg")); + // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. + let first = format!("loaded/{}", s(base.join("outside/a/b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")]; + for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) { + let r = request(&mut app, &tx, "app.open", json!({"path": sent})); + assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); + } + let r = request(&mut app, &tx, "test.status", json!({"guess": path})); + assert_eq!(r["result"], "Loaded b.svg", "{r}"); + let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an +/// error the path outside the read root that the request sent. +#[test] +fn a_screenshot_reply_hides_paths_like_any_reply() { + let (mut app, _tx, base) = confined("deferred-reply"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + let sent = s(base.join("outside/shot.png")); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}}); + let mut failed = json!({"ok": false, "error": format!("could not write {sent}")}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent})); + crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent})); + }); + assert_eq!(done["result"]["note"], "from b.svg", "{done}"); + assert_eq!(failed["error"], "could not write shot.png", "{failed}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped +/// shows the file name alone. +#[test] +fn a_reply_hides_a_path_with_escaped_separators() { + let (mut app, tx, base) = confined("escaped-separators"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from b.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and +/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and +/// other text with a separator stay. +#[test] +fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-relative"); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}], + ["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]); + assert_eq!(steps, &want, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// Two libraries loaded from outside the read root under the same file name, only the second of them /// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and /// that id finds it. @@ -473,6 +873,43 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } +/// A library panel command given a hidden library's shown id opens the library and answers with +/// that id, not the library's path. +#[test] +fn a_library_panel_command_answers_with_a_hidden_librarys_shown_id() { + let (mut app, tx, base) = confined("panel-shown-id"); + let path = base.join("outside/x/Kit.gpl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "GIMP Palette\nName: Kit\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.swatchLibrary", "params": {"library": "loaded/Kit.gpl"}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(r.to_string().contains("loaded/Kit.gpl") && !r.to_string().contains("outside"), "{r}"); + assert_eq!(app.ui.library_panels.last().map(|p| p.id.clone()), Some(format!("loaded/{}", s(path))), "the app's own id"); + // The panel id it answers with names the panel in the panel commands. + let panel = r["result"]["panel"].clone(); + assert_eq!(panel, "library:swatches:loaded/Kit.gpl", "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!((&r["ok"], &r["result"]["panel"]), (&json!(true), &panel), "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "swatches", "anchor": panel, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.panel.close", "params": {"panel": panel}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + // Closed, the panel isn't found, and the error names it by the id given. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -611,6 +1048,103 @@ fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable +/// library's id that begins with it stays, and a script's report line and a command palette query +/// that end with it show the id shown. +#[test] +fn ui_inspect_rewrites_only_whole_hidden_library_ids() { + let (mut app, tx, base) = confined("inspect-whole-id"); + let hidden = format!("loaded/{}", s(base.join("i"))); + let readable = format!("loaded/{}", s(base.join("in/a.svg"))); + assert!(readable.starts_with(&hidden)); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; + app.ui.palette_query = format!("library:swatches:{hidden}"); + let text = format!("found {hidden}"); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}"); + // A request that names the hidden library by the id shown keeps the readable id too. + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id +/// reopens the closed panel of the library, and the reply does not show its path. +#[test] +fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { + let (mut app, tx, base) = confined("set-hidden-panel"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let real = format!("library:swatches:{theirs}"); + app.ui.docking_hidden.insert(real.clone(), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "library:swatches:loaded/b.svg"; + assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); + let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // A docking command takes the shown id as its destination too. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // So does `ui.dock`'s move, whose destination is `target`. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "ui.dock", "params": {"operation": "move", "panel": "properties", "target": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // An error that quotes the panel shows the id the client gave, and other text is not rewritten. + let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); + assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A shortcut for showing the panel of a library outside the read root, saved and in an open Keyboard +/// Shortcuts dialog, is keyed by the panel id shown, also when nothing else refers to the library. +#[test] +fn ui_inspect_hides_a_library_panels_path_in_shortcut_overrides() { + let (mut app, tx, base) = confined("shortcut-hidden-panel"); + let real = format!("panel:library:swatches:loaded/{}", s(base.join("outside/b.svg"))); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &real, "Cmd+Alt+Shift+F7"); + app.ui.dialog = Some(crate::state::Dialog::new("shortcuts", json!({"overrides": { real.clone(): "Cmd+Alt+Shift+F8" }}))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "panel:library:swatches:loaded/b.svg"; + let ui = &r["result"]["ui"]; + assert_eq!(ui["shortcut_overrides"][shown], "Cmd+Alt+Shift+F7", "{r}"); + assert_eq!(ui["dialog"]["fields"]["overrides"][shown], "Cmd+Alt+Shift+F8", "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { @@ -723,12 +1257,12 @@ fn automation_cannot_approve_a_plugin_install() { assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); assert!(vectorcraft_plugins::registry::get(id).is_none()); - // What the dialog installs isn't in its fields. + // What the dialog installs isn't in its fields, and its `__` fields can't be set. let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); // What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`. let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); - assert!(r.get("error").is_none(), "{r}"); + refused(&r, "cannot set protected field `__params`"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); refused(&r, ASKED); assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); @@ -756,6 +1290,231 @@ fn automation_cannot_approve_a_plugin_install() { app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// An app with a control channel and a new document. +fn controlled() -> (VectorcraftApp, std::sync::mpsc::Sender) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + (app, tx) +} + +/// The fields of the open dialog. +fn dialog_fields(app: &VectorcraftApp) -> Option> { + app.ui.dialog.as_ref().map(|d| d.fields.clone()) +} + +/// The objects on the document's first layer. +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) +} + +/// The left edge and width of the first object on the document's first layer. +fn first_object(app: &VectorcraftApp) -> (f64, f64) { + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + (b.x0, b.width()) +} + +/// Each of `sets` sent as `ui.dialog.set` is refused with the protected-field error of the +/// `kind` dialog, sets no field, and runs nothing. +fn refused_sets(app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender, kind: &str, sets: &[Value]) { + for set in sets { + let before = dialog_fields(app); + let r = request(app, tx, "ui.dialog.set", set.clone()); + let says = format!("the `{kind}` dialog cannot set protected field `__"); + assert!(r["error"].as_str().is_some_and(|e| e.starts_with(&says)), "{set}: {r}"); + assert_eq!(dialog_fields(app), before, "{set}: no field changes and the dialog stays open"); + assert_eq!(objects(app), 0, "{set}: nothing ran"); + } +} + +/// A confirmation a person opened runs the command it was opened with: `ui.dialog.set` can't +/// change its `__command` or `__params`, alone or in a list with other fields and `action: +/// "confirm"`, and OK then runs the command it asked about. +#[test] +fn dialog_set_cannot_redirect_person_opened_confirm() { + let (mut app, tx) = controlled(); + let rect = json!({"x": 10, "y": 10, "width": 20, "height": 20}); + crate::dialogs::confirm::ask(&mut app, "Add a rectangle?", "", "shape.rectangle", rect); + let other = json!({"x": 0, "y": 0, "width": 90, "height": 90}); + refused_sets( + &mut app, + &tx, + crate::dialogs::confirm::KIND, + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": other}), + json!({"fields": [{"field": "detail", "value": "Sure?"}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + json!({"fields": [{"field": "__params", "value": other}, {"field": "detail", "value": "Sure?"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "detail", "value": "Sure?"}], "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the command it asked about"); + assert_eq!(first_object(&app), (10.0, 20.0)); +} + +/// The generic parameter dialog runs its command with its fixed parameters: `ui.dialog.set` can +/// change the parameters it shows, not `__command`, `__params` or `__label`. +#[test] +fn dialog_set_cannot_redirect_parameter_dialog() { + let (mut app, tx) = controlled(); + let fields = json!({"__command": "shape.rectangle", "__label": "Rectangle", "__params": {"x": 10, "y": 10}, "width": 20, "height": 20}); + app.ui.dialog = Some(Dialog::new("command", fields)); + refused_sets( + &mut app, + &tx, + "command", + &[ + json!({"field": "__command", "value": "file.close"}), + json!({"field": "__params", "value": {"x": 500, "y": 500}}), + json!({"field": "__label", "value": "Close"}), + json!({"fields": [{"field": "width", "value": 30}, {"field": "__command", "value": "file.close"}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "width", "value": 30, "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.is_none() && objects(&app) == 1, "OK ran the dialog's command"); + assert_eq!(first_object(&app), (10.0, 30.0), "with its fixed x and the width set"); +} + +/// A plug-in command's dialog runs that command of that plug-in: `ui.dialog.set` can't change its +/// `__command` or `__plugin`. +#[test] +fn dialog_set_cannot_redirect_plugin_command() { + let (mut app, tx) = controlled(); + let fields = json!({"__plugin": "test.ui.redirect", "__command": "plugin.test.ui.redirect.a", "__label": "A", "amount": 1}); + app.ui.dialog = Some(Dialog::new(crate::dialogs::plugin::KIND, fields)); + refused_sets( + &mut app, + &tx, + crate::dialogs::plugin::KIND, + &[ + json!({"field": "__command", "value": "plugin.test.ui.redirect.b"}), + json!({"field": "__plugin", "value": "test.ui.other"}), + json!({"fields": [{"field": "amount", "value": 2}, {"field": "__command", "value": ""}], "action": "confirm"}), + ], + ); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "amount", "value": 2})); + assert_eq!(r["ok"], true, "{r}"); +} + +/// A `__` field the dialog does not hold is refused as a protected field too, and so is a control +/// of another kind of dialog; the error for an unknown field lists the dialog's own controls only. +#[test] +fn dialog_set_rejects_unknown_hidden_field() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + for field in ["__anything", "__section", "__ratio", "__constrainApplied"] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": true})); + assert_eq!(r["error"], format!("the `transformEach` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key("__anything")); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true})); + let error = r["error"].as_str().unwrap_or_default(); + assert!(error.contains("has no field `nosuch`") && error.contains("__constrain"), "{r}"); + assert!(!error.contains("__ratio") && !error.contains("__section"), "{r}"); + + // Artboard Options' Fit, size preset and reference point flags are set by their buttons + // together with the size and position they record. + app.ui.dialog = Some(Dialog::new("artboardOptions", json!({"width": 60, "height": 20}))); + for (field, value) in [("__fit", json!(true)), ("__sizeChoice", json!({"landscape": true})), ("__refx", json!(2)), ("__refy", json!(2))] { + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(r["error"], format!("the `artboardOptions` dialog cannot set protected field `{field}`"), "{r}"); + assert!(!dialog_fields(&app).unwrap().contains_key(field)); + } + // Scribble's Settings menu sets the preset's parameters along with its name. + app.ui.dialog = Some(Dialog::new("effect", json!({"__effect": "scribble", "angle": 30}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__scribbleSetting", "value": "sharp"})); + assert_eq!(r["error"], "the `effect` dialog cannot set protected field `__scribbleSetting`", "{r}"); +} + +/// The dialog controls held under a `__` name are still set: Transform Each's link, the Save PDF +/// section and its All artboards checkbox, and the Preferences search. +#[test] +fn dialog_set_allows_hidden_controls() { + let (mut app, tx) = controlled(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__constrain", "value": true})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.bool("__constrain"))); + app.ui.dialog = None; + + app.run("ui.savePdfDialog", json!({})).unwrap(); + let set = json!({"fields": [{"field": "__section", "value": "Security"}, {"field": "__allArtboards", "value": false}]}); + let r = request(&mut app, &tx, "ui.dialog.set", set); + assert_eq!(r["ok"], true, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__section"), d.bool("__allArtboards")), ("Security".to_string(), false)); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__save", "value": "file.saveCopy"})); + assert_eq!(r["error"], "the `savePdf` dialog cannot set protected field `__save`", "{r}"); + app.ui.dialog = None; + + // The raster options dialogs share the Resolution menu's Other, PSD Options included. + for kind in ["pngOptions", "tiffOptions", "psdOptions"] { + app.ui.dialog = Some(Dialog::new(kind, json!({"ppi": 72}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__otherPpi", "value": true})); + assert_eq!(r["ok"], true, "{kind}: {r}"); + } + app.ui.dialog = None; + + // A style section's reset flag alone would keep the section's values: scripts run the Reset + // Panel commands, which clear them. Which of the two styles a new style is stays fixed. + for (kind, para, flag) in [ + ("charStyleOptions", false, "__resetColor"), + ("paraStyleOptions", true, "__resetHyphenation"), + ("newTextStyle", false, "__resetColor"), + ("newTextStyle", true, "__resetHyphenation"), + ] { + app.ui.dialog = Some(Dialog::new(kind, json!({"name": "A", "__para": para}))); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": flag, "value": true})); + assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `{flag}`"), "{r}"); + assert!(!app.ui.dialog.as_ref().is_some_and(|d| d.bool(flag)), "{kind}"); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__para", "value": false})); + assert_eq!(r["error"], "the `newTextStyle` dialog cannot set protected field `__para`", "{r}"); + app.ui.dialog = None; + + crate::prefs_dialog::open(&mut app, None); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__search", "value": "grid"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("__search")), Some("grid".to_string())); +} + +/// Save PDF's and Print's OK save a preset while Save Preset… asks for its name: `ui.dialog.set` +/// can't add `__savePresetAs` to turn OK into saving a preset, alone or with `action: "confirm"`, +/// and can type the name once Save Preset… has added the field. +#[test] +fn dialog_set_cannot_open_save_preset() { + let (mut app, tx) = controlled(); + let saved = |app: &VectorcraftApp| (app.session.prefs.pdf_presets.len(), app.session.prefs.print_presets.len()); + for (open, kind) in [("ui.savePdfDialog", "savePdf"), ("file.print", "print")] { + app.run(open, json!({})).unwrap(); + let presets = saved(&app); + for set in [ + json!({"field": "__savePresetAs", "value": "Proofs"}), + json!({"fields": [{"field": "__savePresetAs", "value": "Proofs"}], "action": "confirm"}), + ] { + let before = dialog_fields(&app); + let r = request(&mut app, &tx, "ui.dialog.set", set.clone()); + assert_eq!(r["error"], format!("the `{kind}` dialog cannot set protected field `__savePresetAs`"), "{set}: {r}"); + assert_eq!(dialog_fields(&app), before, "{set}: no field changes and the dialog stays open"); + } + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "nosuch", "value": true})); + assert!(!r["error"].as_str().unwrap_or_default().contains("__savePresetAs"), "{kind}: {r}"); + assert_eq!(saved(&app), presets, "{kind}: no preset saved"); + // Save Preset… adds the field; the name can then be typed and OK saves the preset. + app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!("")); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__savePresetAs", "value": "Proofs", "action": "confirm"})); + assert_eq!(r["result"]["result"]["name"], "Proofs", "{kind}: {r}"); + app.ui.dialog = None; + } + assert_eq!(saved(&app), (1, 1)); +} + /// The refusal of an OK on an install question automation asked. const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; @@ -999,7 +1758,8 @@ fn automation_cannot_approve_replacing_a_plugin() { assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND})); let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#)); let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]})); - assert!(r.get("error").is_none(), "{r}"); + let protected = format!("the `{}` dialog cannot set protected field `__params`", crate::dialogs::plugin::INSTALL_KIND); + assert_eq!(r["error"], protected, "{r}"); super::plugins::show_approval(&mut app); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}"); diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index ea79c1d5cc..109122308f 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -959,3 +959,142 @@ fn a_preview_stays_put_while_the_menu_is_still() { assert_eq!(picked, Some(FontPick::EndPreview)); assert_eq!(font(&app), "Inter"); } + +/// What an open font menu drew: each text with its rectangle and clip rectangle, the rectangles of +/// the samples, and the menu's rectangle. +struct Drawn { + texts: Vec<(String, egui::Rect, egui::Rect)>, + samples: Vec, + menu: egui::Rect, +} + +impl Drawn { + fn text(&self, label: &str) -> egui::Rect { + self.texts.iter().find(|(t, ..)| t == label).map(|(_, r, _)| *r).unwrap_or_else(|| panic!("{label} is drawn")) + } + + /// The filters' rows, each by the centers of its first and last chips, in the UI's language. + fn filter_rows(&self) -> Vec<(f32, f32)> { + use crate::i18n::{current, t, tr_ctx}; + let variable = tr_ctx(current(), "font-technology", "Variable"); + [(t("All Scripts"), t("Thai")), ("★", t("Rounded")), (t("Script / Brush"), t("Decorative")), (t("Font Technology"), variable)] + .map(|(first, last)| (self.text(first).center().y, self.text(last).center().y)) + .to_vec() + } + + /// The filters lie on their four rows, none wrapped. + fn assert_filter_rows(&self) { + let rows = self.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "filter row {i} wraps: its first chip at {first}, its last at {last}"); + } + assert!(rows.windows(2).all(|w| w[1].0 > w[0].0 + 4.0), "four rows of filters, in order: {rows:?}"); + } +} + +/// Opens the font menu of a field `width` wide whose left edge is `left` points into a window of +/// size `screen`, draws it until it settles and its samples are rendered, and returns what the +/// last frame drew. +fn open_menu(id: &str, left: f32, width: f32, screen: egui::Vec2) -> Drawn { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let field = egui::Rect::from_min_size(egui::pos2(left, 10.0), egui::vec2(width, 30.0)); + let frame = |events: Vec| { + let input = egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + ui.scope_builder(egui::UiBuilder::new().max_rect(field), |ui| { + font_menu(ui, id, "Inter", width, None, MenuLook::default()); + }); + }); + out.textures_delta.clear(); + out.shapes + }; + let click = field.min + egui::vec2(10.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![]); + frame(vec![egui::Event::PointerMoved(click), button(true)]); + frame(vec![button(false)]); + for _ in 0..5 { + frame(vec![]); + } + crate::font_menu::wait_for_samples(&ctx); + let shapes = frame(vec![]); + let mut texts = vec![]; + let mut samples = vec![]; + for s in &shapes { + match &s.shape { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()), s.clip_rect)), + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => samples.push(m.calc_bounds()), + _ => {} + } + } + let mut drawn = Drawn { texts, samples, menu: egui::Rect::NOTHING }; + let filter = drawn.text(crate::i18n::t("Any Kind")).center(); + drawn.menu = ctx.layer_id_at(filter).and_then(|l| ctx.memory(|m| m.area_rect(l.id))).expect("the menu is open"); + drawn +} + +/// 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] +fn a_narrow_field_opens_a_menu_wide_enough_for_the_filters_and_names() { + let field = 115.0; + let d = open_menu("test-narrow", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + assert!(d.menu.width() > 2.0 * field, "the menu is {} wide", d.menu.width()); + let families = FontDb::global().menu_family_list(); + // The field shows the current family too: only the names in the menu's rows. + let names: Vec<_> = d.texts.iter().filter(|(t, r, _)| r.top() > d.text("Variable").bottom() && families.iter().any(|f| f == t)).collect(); + assert!(names.iter().any(|(_, r, _)| r.width() > field / 2.0), "a name wider than half the field is listed: {names:?}"); + // The menu widens itself for names up to its limit; a longer name is cut. + for (name, r, clip) in names.iter().filter(|(_, r, _)| r.width() <= crate::font_menu::NAME_MAX) { + assert!(r.right() <= clip.right() + 0.5, "{name} ends at {} and is cut at {}", r.right(), clip.right()); + } +} + +/// In every UI language the filters keep their four rows unwrapped when the window has room for +/// them: the longest translated labels set the menu's width. +#[test] +fn the_filter_rows_stay_whole_in_every_language() { + for lang in crate::i18n::Lang::all() { + crate::i18n::set_current(lang); + let d = open_menu(&format!("test-lang-{}", lang.code()), 20.0, 115.0, egui::vec2(2400.0, 900.0)); + let rows = d.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "{}: filter row {i} wraps in a menu {} wide", lang.code(), d.menu.width()); + } + } + crate::i18n::set_current(crate::i18n::Lang::EN); +} + +/// A menu opened near the window's right edge is wider than its field and stays in the window. +#[test] +fn a_menu_wider_than_its_field_stays_in_the_window() { + let screen = egui::vec2(700.0, 800.0); + let d = open_menu("test-edge", 600.0, 90.0, screen); + d.assert_filter_rows(); + assert!(d.menu.width() > 200.0, "the menu is {} wide", d.menu.width()); + assert!(d.menu.left() >= -0.5 && d.menu.right() <= screen.x + 0.5, "the menu at {:?} leaves the window", d.menu); +} + +/// A docked panel's field is as wide as the panel: its menu takes that width, with the filters on +/// the same rows as a narrow field's menu. +#[test] +fn a_wide_fields_menu_takes_its_width_with_the_same_filter_rows() { + let field = 480.0; + let d = open_menu("test-docked", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + // The field is drawn 4 points narrower than its width; the menu's frame adds its margins. + assert!(d.menu.width() >= field - 4.0 && d.menu.width() < field + 40.0, "the menu is {} wide", d.menu.width()); +} + +/// Each sample takes its row's whole height. +#[test] +fn samples_fill_their_rows_height() { + let d = open_menu("test-samples", 20.0, 260.0, egui::vec2(1200.0, 800.0)); + assert!(!d.samples.is_empty(), "samples are drawn"); + let row = MenuLook::default().row; + for s in &d.samples { + assert!((s.height() - row).abs() < 0.5, "a sample {} high in a row {row} high", s.height()); + } +} diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 0e1e794b8d..689a0e3a04 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1145,6 +1145,234 @@ fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() { assert_eq!(value.get(), 10.0); } +/// The rest of a notch that a number field took, which egui spreads over the next frames, scrolls +/// no panel after the pointer leaves the field: neither the field's own panel nor a panel drawn +/// before it. A notch turned after that scrolls the panel under the pointer by one whole notch. +#[test] +fn the_rest_of_a_notch_a_number_field_took_scrolls_no_panel_after_the_pointer_leaves() { + for to_other_panel in [false, true] { + let mut f = Field::new(); + let (left, right, value, field) = (Cell::new(0.0), Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + ui.horizontal_top(|ui| { + let out = egui::ScrollArea::vertical().id_salt("left").max_height(120.0).show(ui, |ui| { + ui.allocate_space(vec2(100.0, 600.0)); + }); + left.set(out.state.clamped_offset().y); + let out = egui::ScrollArea::vertical().id_salt("right").max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + right.set(out.state.clamped_offset().y); + out.inner + }) + .inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = if to_other_panel { Pos2::new(50.0, 60.0) } else { Pos2::new(on.x, field.get().bottom() + 40.0) }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((left.get(), right.get(), value.get()), (0.0, 0.0, 9.0), "pointer moved to the other panel: {to_other_panel}"); + assert_eq!(f.frame(vec![notch_down()], &draw), None); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let (scrolled, still) = if to_other_panel { (left.get(), right.get()) } else { (right.get(), left.get()) }; + assert!((scrolled - line).abs() < 0.01 && still == 0.0, "other panel {to_other_panel}: {scrolled} {still}"); + assert_eq!(value.get(), 9.0); + } +} + +/// A wheel touch that starts and never ends (some mouse wheels send no end) stops counting as a +/// touch once no wheel event comes for a while, as egui stops it, so the rest of a notch a number +/// field takes after that still scrolls no panel after the pointer leaves. +#[test] +fn a_wheel_touch_with_no_end_does_not_let_the_rest_of_a_later_notch_scroll_the_panel() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let start = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::Start, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![start], &draw); + for _ in 0..10 { + f.frame(vec![], &draw); + } + assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((offset.get(), value.get()), (0.0, 8.0)); +} + +/// A whole wheel touch (start, turn and end) in one pass that a number field takes leaves nothing +/// for egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_whole_touch_in_one_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let touch = |phase| Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: if phase == egui::TouchPhase::Move { vec2(0.0, -1.0) } else { egui::Vec2::ZERO }, + phase, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + let events = vec![touch(egui::TouchPhase::Start), touch(egui::TouchPhase::Move), touch(egui::TouchPhase::End)]; + assert_eq!(f.frame(events, &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 8.0); +} + +/// A turn and the end of the wheel stream in one pass that a number field takes leave nothing for +/// egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_turn_ended_in_its_own_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + let end = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::End, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down(), end], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 9.0); +} + +/// A small turn the other way on the panel while the rest of a notch a number field took is still +/// being let out scrolls the panel by the whole turn. +#[test] +fn a_turn_the_other_way_while_a_number_field_notch_runs_out_scrolls_the_panel_by_the_whole_turn() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add_space(150.0); + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let above = |field: Rect| Pos2::new(field.center().x, field.top() - 30.0); + f.frame(vec![Event::PointerMoved(Pos2::new(field.get().center().x, 10.0)), notch_down(), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let scrolled = offset.get(); + assert!((scrolled - 2.0 * line).abs() < 0.01, "{scrolled}"); + f.frame(vec![Event::PointerMoved(field.get().center())], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(9.0)); + let up = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 10.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![Event::PointerMoved(above(field.get())), up], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + assert!((offset.get() - (scrolled - 10.0)).abs() < 0.01, "{} {scrolled}", offset.get()); + assert_eq!(value.get(), 9.0); +} + /// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it /// again before it takes the wheel, with or without another number field drawn meanwhile. #[test] diff --git a/crates/ui-egui/src/tests/save.rs b/crates/ui-egui/src/tests/save.rs index b5e534eecf..458511043e 100644 --- a/crates/ui-egui/src/tests/save.rs +++ b/crates/ui-egui/src/tests/save.rs @@ -298,6 +298,101 @@ fn replacing_a_file_that_opening_left_things_out_of_asks_first() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn a_save_does_not_replace_the_file_read_from_through_a_file_written_beside_the_path() { + let d = dir("lossy-beside"); + let path = d.join("Art.eps").to_string_lossy().to_string(); + let mut app = app(); + checks_files_there(&mut app); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut app); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let _ = app.run("file.saveAs", json!({"path": path, "options": options})); + crate::background::wait_all(&mut app); + let siblings = artboard_files(&d); + assert_eq!(siblings.len(), 2, "{siblings:?}"); + let sibling = siblings[0].clone(); + let saved = std::fs::read(&sibling).unwrap(); + std::fs::remove_file(&path).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some(sibling.clone()); + st.import_losses = vec!["hidden type".into()]; + rect(&mut app); + let _ = app.run("file.saveAs", json!({"path": path, "options": options})); + crate::background::wait_all(&mut app); + // The document's last save wrote the files beside the path, so the save does not ask whether + // to replace them: it is refused. + assert!(app.ui.dialog.as_ref().is_none_or(|d| d.kind != dialogs::confirm::REPLACE_FILES)); + assert_eq!(std::fs::read(&sibling).unwrap(), saved); + assert!(!std::path::Path::new(&path).exists(), "no file of the set is written when one is refused"); + let _ = std::fs::remove_dir_all(d); +} + +/// The app checks for files that are there, as the desktop app does. +fn checks_files_there(app: &mut VectorcraftApp) { + app.services.exists = Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())); +} + +/// The files in `d` that an EPS save of `Art.eps` with Use Artboards wrote beside it, sorted. +fn artboard_files(d: &std::path::Path) -> Vec { + let mut files: Vec = std::fs::read_dir(d) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("Art_"))) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + files.sort(); + files +} + +#[test] +fn an_eps_save_with_use_artboards_asks_before_replacing_an_artboard_file_that_is_there() { + let d = dir("eps-artboards-there"); + let path = d.join("Art.eps").to_string_lossy().to_string(); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let save_as = |app: &mut VectorcraftApp| { + let _ = app.run("file.saveAs", json!({"path": path, "options": options})); + crate::background::wait_all(app); + }; + // Another document's save leaves files with the names of the artboard files. + let mut other = app(); + other.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + save_as(&mut other); + let there = artboard_files(&d); + assert_eq!(there.len(), 2, "{there:?}"); + std::fs::remove_file(&path).unwrap(); + for f in &there { + std::fs::write(f, b"other").unwrap(); + } + let mut app = app(); + checks_files_there(&mut app); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut app); + save_as(&mut app); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.kind, dialogs::confirm::REPLACE_FILES); + assert_eq!(dialog.fields["files"], json!(there)); + assert!(!std::path::Path::new(&path).exists(), "nothing is written while it asks"); + assert!(there.iter().all(|f| std::fs::read(f).unwrap() == b"other")); + // Cancel writes nothing. + dialogs::cancel(&mut app); + assert!(!std::path::Path::new(&path).exists()); + assert!(there.iter().all(|f| std::fs::read(f).unwrap() == b"other")); + // OK writes every file. + save_as(&mut app); + dialogs::confirm(&mut app).unwrap(); + crate::background::wait_all(&mut app); + assert!(std::path::Path::new(&path).exists()); + assert!(there.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS"))); + // The document's next save writes them again without asking. + rect(&mut app); + let _ = app.run("file.save", json!({})); + crate::background::wait_all(&mut app); + assert!(app.ui.dialog.is_none()); + assert!(!app.session.active().unwrap().is_dirty()); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn native_saves_stream_into_the_file() { let d = dir("stream"); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6a90cdfac0..b1788e1e8a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info}; +use crate::panels::learn::locate as learn_locate; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, widgets}; @@ -85,6 +86,17 @@ pub(crate) fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'st } } +/// The tool a slot's button shows: the active tool when the slot holds it, else the one chosen +/// last from the slot's group, else its first. +pub(crate) fn shown_tool(app: &VectorcraftApp, slot: &[&'static str]) -> &'static str { + let active = app.session.tool_id(); + if slot.contains(&active) { + return active; + } + let first = slot.first().copied().unwrap_or_default(); + app.ui.slot_tool.get(first).and_then(|id| slot.iter().copied().find(|s| s == id)).unwrap_or(first) +} + /// Remember the tool shown in the slot that contains `id`. pub fn remember(app: &mut VectorcraftApp, id: &str) { for (_, s) in slots(app) { @@ -253,6 +265,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option = None; let labels = app.session.prefs.tool_group_labels; + let locate = learn_locate::wanted(&app.ui, None); let mut i = 0; while i < all.len() { if all[i].0.is_some() && !labels { @@ -281,12 +294,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option 1 && is_floating(app, None, slot[0])) + { + if !ui.clip_rect().contains_rect(rect) && learn_locate::scroll_once(&mut app.ui) { + ui.scroll_to_rect(rect, None); + ui.ctx().request_repaint(); + } else if shown.id == tool { + learn_locate::mark(app, ui.ctx(), ui.layer_id(), ui.clip_rect(), rect); + } else if learn_locate::reveal_once(&mut app.ui) { + open_flyout = Some((slot.clone(), rect)); + } + } if editing { buttons.push((k, rect)); resp.dnd_set_drag_payload(crate::edit_toolbar::ToolDrag { tool: shown.id, from_toolbar: true }); @@ -416,8 +438,7 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result Ok(json!({ "open": tool })), - "gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })), + "gradient" | "magicWand" => crate::panel_docking::reveal_panel(app, tool), "artboard" => crate::dialogs::artboard_options::open(app), "eyedropper" => { crate::dialogs::eyedropper::open(app); @@ -723,20 +744,62 @@ struct FlyoutInput { bar: Option, /// A floating flyout's × was clicked. close: bool, + /// Where the tool a Learn link locates is drawn. + located: Option, + /// What a docked flyout's rows are clipped to (they scroll in a group taller than the window). + located_clip: Option, } /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their /// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag /// that tears a flyout off carries on moving the floating flyout it becomes (`toolbar`: the toolbar -/// made with New Toolbar the group is in, none for the Tools panel's). -fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>) -> FlyoutInput { +/// made with New Toolbar the group is in, none for the Tools panel's; `locate`: the tool a Learn +/// link locates, whose place it returns; `reveal_row`: a located row outside the flyout's view +/// scrolls into it, and is returned once it shows). +#[allow(clippy::too_many_arguments)] +fn flyout_body( + ui: &mut Ui, + t: &Tokens, + active: &str, + tools: &[String], + floating: bool, + toolbar: Option<&str>, + locate: Option<&str>, + reveal_row: bool, +) -> FlyoutInput { let mut out = FlyoutInput::default(); let key = tools.first().map_or("", String::as_str); let bar_id = bar_id(toolbar, key); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); ui.horizontal(|ui| { - let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) }; - out.chosen = rows.inner; + let rows = if floating { + ui.horizontal(|ui| tool_strip(ui, t, active, tools, locate)) + } else { + // A group taller than the window scrolls its rows (inside the frame's 1 pt border). The + // rows' height is given, since the row this sits in starts one widget tall. + let max = ui.ctx().content_rect().height() - 2.0; + let height = (tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * ROW_HEIGHT).min(max); + ui.vertical(|ui| { + egui::ScrollArea::vertical() + .id_salt(bar_id.with("rows")) + .max_height(max) + .min_scrolled_height(height) + .show(ui, |ui| { + let (chosen, located) = tool_rows(ui, t, active, tools, locate); + // The mark is clipped to the rows' view, not to the whole flyout. + out.located_clip = Some(ui.clip_rect()); + match located.filter(|r| reveal_row && !ui.clip_rect().contains_rect(*r)) { + Some(r) => { + ui.scroll_to_rect_animation(r, Some(egui::Align::Center), egui::style::ScrollAnimation::none()); + (chosen, None) + } + None => (chosen, located), + } + }) + .inner + }) + }; + (out.chosen, out.located) = rows.inner; let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.tab_strip); let grip = if floating { @@ -769,9 +832,11 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating out } -/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked. -fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { +/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked +/// and the row of `locate`. +fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option) { let mut chosen = None; + let mut located = None; let lang = crate::i18n::current(); let color = |tool: &ToolInfo| if active == tool.id { t.flyout_active } else { t.text_strong }; let rows: Vec<(&ToolInfo, RowText)> = @@ -779,7 +844,7 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< let w = rows_width(rows.iter().map(|(_, text)| text)); for (tool, (name, shortcut)) in rows { let is_active = active == tool.id; - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW_HEIGHT), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } @@ -797,8 +862,11 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< if resp.clicked() { chosen = Some(tool.id); } + if locate == Some(tool.id) { + located = Some(r); + } } - chosen + (chosen, located) } /// A flyout row's name and its shortcut, laid out. @@ -809,6 +877,8 @@ type RowText = (Arc, Option>); const NAME_X: f32 = 52.0; const NAME_GAP: f32 = 14.0; const ROW_END: f32 = 18.0; +/// The height of a flyout row. +const ROW_HEIGHT: f32 = 30.0; /// The name of `tool` in `lang` and its shortcut in brackets, as a flyout row draws them. fn row_text(ui: &Ui, lang: crate::i18n::Lang, tool: &ToolInfo, color: Color32) -> RowText { @@ -842,9 +912,10 @@ fn grab_id(toolbar: Option<&str>, key: &str) -> egui::Id { } /// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the -/// tool clicked. -fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> { +/// tool clicked and the button of `locate`. +fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option) { let mut chosen = None; + let mut located = None; for tool in tools.iter().filter_map(|id| tool_info(id)) { let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click()); let well = r.shrink(1.0); @@ -859,8 +930,11 @@ fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option if resp.on_hover_text(tip(tool)).clicked() { chosen = Some(tool.id); } + if locate == Some(tool.id) { + located = Some(r); + } } - chosen + (chosen, located) } /// The width of a floating flyout's strip of `tools`, its bar included. @@ -908,14 +982,22 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let active = app.session.tool_id(); let toolbar = ctx.data(|d| d.get_temp::>(flyout_toolbar_id())).flatten(); + let locate = learn_locate::wanted(&app.ui, toolbar.as_deref()).filter(|tool| tools.iter().any(|id| id == tool)); + let reveal_row = learn_locate::unmarked(&app.ui); let mut input = FlyoutInput::default(); // Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right. let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size()); let x = beside(anchor, size.x, 8.0, room(app, ctx)); + // The area stays inside the window (egui's `Area::constrain`, on by default): a flyout opened + // near the bottom moves up, and a group taller than the window scrolls a located row into view. let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| { - flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref())); + flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate, reveal_row)); }); + if let Some(rect) = input.located { + let clip = input.located_clip.map_or(resp.response.rect, |c| c.intersect(resp.response.rect)); + learn_locate::mark(app, ctx, resp.response.layer_id, clip, rect); + } // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the // end of the long press that opened the flyout): it floats where the pointer is. let at = ctx.input(|i| i.pointer.interact_pos()); @@ -998,10 +1080,19 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let active = app.session.tool_id(); let mut chosen = None; let mut closed = None; + // A Learn link locating a tool of a strip raises the strip once, then marks the tool in it. + let locate = learn_locate::wanted(&app.ui, toolbar); + let raise = locate.is_some_and(|tool| strips(&app.ui, toolbar).iter().any(|f| f.tools.iter().any(|id| id == tool))) + && learn_locate::reveal_once(&mut app.ui); + let mut located = None; let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return }; for (k, f) in strips.iter_mut().enumerate() { let Some(key) = f.tools.first() else { continue }; let id = strip_area(toolbar, key); + let locate = locate.filter(|tool| f.tools.iter().any(|t| t == tool)); + if raise && locate.is_some() { + raise_floating(ctx, toolbar, key); + } let since = ctx.data(|d| d.get_temp::(id)).map(|t0| now - t0); let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s)); if let Some(s) = since.filter(|_| flash) { @@ -1011,9 +1102,12 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size()); let pos = crate::tools_float::clamp(f.pos, size, screen); let mut input = FlyoutInput::default(); - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar)); + let area = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { + flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar, locate, false)); }); + if let Some(rect) = input.located { + located = Some((area.response.layer_id, area.response.rect, rect)); + } // Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than // each frame's motion, which a just torn-off flyout's first, unseen frame would drop). let grab = grab_id(toolbar, key); @@ -1036,6 +1130,9 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio if let Some(k) = closed.filter(|k| *k < strips.len()) { strips.remove(k); } + if let Some((layer, clip, rect)) = located { + learn_locate::mark(app, ctx, layer, clip, rect); + } if let Some(id) = chosen { app.select_tool(id); } diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index e8e5d8f12c..eb45d08533 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -155,6 +155,15 @@ fn more_tools_open_their_options() { assert!(crate::panel_docking::showing(&app.ui, "magicWand")); open_options(&mut app, "magicWand").unwrap(); assert!(crate::panel_docking::showing(&app.ui, "magicWand")); + // With the panels hidden by Tab, the double-click shows them with the panel. + app.run("window.dock", json!({})).unwrap(); + open_options(&mut app, "magicWand").unwrap(); + assert!(app.ui.dock && crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel shows"); + // In Presentation Mode the double-click does not fail, and the panel shows when the mode ends. + app.ui.screen_mode = 3; + open_options(&mut app, "magicWand").unwrap(); + app.ui.screen_mode = 0; + assert!(crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel stays in place"); } /// A double-click at `at`, `time` seconds in (a second apart from the last). @@ -1054,3 +1063,338 @@ fn tab_skips_the_grip_and_the_tear_off_bars() { assert!(!stops.contains(&id), "Tab stops on {name}: {stops:?}"); } } + +use crate::panels::learn::locate::{LIFETIME, MARK_WIDTH}; + +/// One frame of the toolbar in a window 400 points wide and `height` tall, then what ends a Learn +/// locate (`learn.locateTool`): the marks the locate drew, and the screen-reader nodes. +fn locate_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + time: f64, + events: Vec, + height: f32, +) -> (Vec, Vec) { + fn marks(s: &egui::Shape, color: Color32, out: &mut Vec) { + match s { + egui::Shape::Rect(r) if r.stroke.width == MARK_WIDTH && r.stroke.color == color => out.push(r.rect), + egui::Shape::Vec(v) => v.iter().for_each(|s| marks(s, color, out)), + _ => {} + } + } + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, height))); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), events, screen_rect, ..Default::default() }, |ui| { + show(app, ui); + crate::panels::learn::locate::tidy(app, ui.ctx()); + }); + out.textures_delta.clear(); + let color = Tokens::get(ctx).selection; + let mut found = vec![]; + out.shapes.iter().for_each(|c| marks(&c.shape, color, &mut found)); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (found, nodes) +} + +/// A context that makes screen-reader nodes. +fn reader_context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + ctx +} + +/// Where the screen-reader node named after `tool`'s tool tip is drawn. +fn tool_rect(nodes: &[egui::accesskit::Node], tool: &str) -> egui::Rect { + let name = tool_info(tool).map(tip).unwrap_or_default(); + let b = nodes.iter().find(|n| n.label().is_some_and(|l| l == name)).and_then(|n| n.bounds()).unwrap_or_else(|| panic!("{name} is drawn")); + egui::Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)) +} + +/// The only mark drawn, on `control`. +fn assert_marks(marks: &[egui::Rect], control: egui::Rect, what: &str) { + let [mark] = marks else { panic!("{what}: one mark, not {marks:?}") }; + let want = control.shrink(1.0); + assert!((mark.min - want.min).length() < 0.5 && (mark.max - want.max).length() < 0.5, "{what}: the mark {mark:?} is on {control:?}"); +} + +/// A Learn link's `learn.locateTool` marks a tool shown on its button, with Tools docked left or +/// right, floating or in two columns; it does not choose the tool, and the mark goes after +/// [`LIFETIME`] seconds. +#[test] +fn a_learn_link_marks_the_tools_button_wherever_tools_is() { + for (right, floating, double) in [(false, None, false), (true, None, false), (false, Some([120.0, 80.0]), false), (false, None, true)] { + let what = format!("right {right}, floating {floating:?}, double {double}"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + (app.ui.toolbar_right, app.ui.toolbar_pos, app.ui.toolbar_double) = (right, floating, double); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(), json!({"tool": "pen", "location": "toolbar", "toolbar": null})); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "pen"), &what); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("selection", None), "{what}: no tool chosen, no flyout"); + assert_eq!(locate_frame(&mut app, &ctx, 1.0 + LIFETIME - 0.1, vec![], 1200.0).0.len(), 1, "{what}: still marked"); + assert!(locate_frame(&mut app, &ctx, 1.0 + LIFETIME + 0.01, vec![], 1200.0).0.is_empty(), "{what}: the mark has gone"); + assert!(app.ui.learn_locate.is_none(), "{what}"); + } +} + +/// A tool in a collapsed group opens the group's flyout with its row marked, also while another +/// tool of the group shows on the button; the group keeps the tool it shows. +#[test] +fn a_learn_link_opens_a_collapsed_group_on_the_tools_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.slot_tool.insert("pencil".into(), "pathEraser".into()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + for (k, tool) in ["smooth", "pencil"].into_iter().enumerate() { + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + // The flyout's first frame only measures it. + locate_frame(&mut app, &ctx, 1.0 + k as f64, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.1 + k as f64, vec![], 1200.0); + assert!(app.ui.flyout.is_some(), "{tool}: the flyout opens"); + assert_marks(&marks, tool_rect(&nodes, tool), tool); + let row = tool_rect(&nodes, tool); + let flyout = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows"); + assert!(flyout.contains_rect(row), "{tool}: a row of the flyout ({row:?} in {flyout:?})"); + assert_eq!(ctx.data(|d| d.get_temp::>(egui::Id::unique("flyout-tools"))).map(|t| t[0].clone()).as_deref(), Some("pencil")); + } + assert_eq!(app.ui.slot_tool.get("pencil").map(String::as_str), Some("pathEraser"), "the group still shows the Path Eraser"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// A tool of a group torn off into a strip: the strip comes to the front with the tool marked in it. +#[test] +fn a_learn_link_raises_a_torn_off_group_and_marks_the_tool_in_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.floatTools", json!({"tool": "pen"})).unwrap(); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "addAnchor"})).unwrap()["location"], "strip"); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert!(ctx.data(|d| d.get_temp::(floating_area("pen"))).is_some(), "the strip was raised"); + let strip = ctx.memory(|m| m.area_rect(floating_area("pen"))).unwrap(); + let button = tool_rect(&nodes, "addAnchor"); + assert!(strip.contains_rect(button), "the strip's button: {button:?} in {strip:?}"); + assert_marks(&marks, button, "the strip"); + assert_eq!(app.ui.flyout, None); +} + +/// A tool Edit Toolbar took out of the toolbar is marked in the All Tools drawer, which opens; the +/// toolbar stays as it was. +#[test] +fn a_learn_link_marks_a_tool_no_toolbar_holds_in_all_tools() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("toolbar.removeTool", json!({"tool": "rectangle"})).unwrap(); + let before = slots(&app); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "rectangle"})).unwrap()["location"], "allTools"); + assert!(app.ui.tools_drawer && app.ui.drawer_toolbar.is_none()); + // The drawer's first frame only measures it. + locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.1, vec![], 1200.0); + let drawer = ctx.memory(|m| m.area_rect(crate::edit_toolbar::drawer_id())).expect("the drawer shows"); + let row = tool_rect(&nodes, "rectangle"); + assert!(drawer.contains_rect(row), "the drawer's row: {row:?} in {drawer:?}"); + assert_marks(&marks, row, "the drawer"); + assert_eq!(slots(&app), before, "no tool added to the toolbar"); + // Escape closes the drawer first (the shortcuts consume it), and ends the locate too. + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))); + let _ = ctx.run_ui(egui::RawInput { time: Some(1.2), events: vec![escape], screen_rect, ..Default::default() }, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + show(&mut app, ui); + crate::panels::learn::locate::tidy(&mut app, ui.ctx()); + }); + assert!(!app.ui.tools_drawer, "Escape closed the drawer"); + assert!(app.ui.learn_locate.is_none(), "and ended the locate"); +} + +/// A tool on a toolbar made with New Toolbar that the Tools panel does not hold is marked there. +#[test] +fn a_learn_link_marks_a_tool_on_a_toolbar_made_with_new_toolbar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "artboard", "toolbar": "Mine"})).unwrap(); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + assert_eq!( + app.run("learn.locateTool", json!({"tool": "artboard"})).unwrap(), + json!({"tool": "artboard", "location": "toolbar", "toolbar": "Mine"}) + ); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).unwrap(); + let button = tool_rect(&nodes, "artboard"); + assert!(bar.contains_rect(button), "{button:?} in {bar:?}"); + assert_marks(&marks, button, "the toolbar Mine"); + // A tool of a group whose button shows another: the group's flyout opens on it. + app.run("toolbar.addTool", json!({"tool": "slice", "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap(); + assert_eq!(app.run("learn.locateTool", json!({"tool": "slice"})).unwrap(), json!({"tool": "slice", "location": "flyout", "toolbar": "Mine"})); + locate_frame(&mut app, &ctx, 2.0, vec![], 1200.0); + let (marks, nodes) = locate_frame(&mut app, &ctx, 2.1, vec![], 1200.0); + let flyout = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows"); + let row = tool_rect(&nodes, "slice"); + assert!(flyout.contains_rect(row), "{row:?} in {flyout:?}"); + assert_marks(&marks, row, "the flyout of the toolbar Mine"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// Tools hidden with Window › Tools shows again for a Learn link, and a short window scrolls the +/// tool into view. +#[test] +fn a_learn_link_shows_hidden_tools_and_scrolls_the_tool_into_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.toolbar = false; + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": "gradient"})).unwrap()["location"], "toolbar"); + assert!(app.ui.toolbar, "Tools shows"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let button = tool_rect(&nodes, "gradient"); + assert!(button.top() >= 0.0 && button.bottom() <= 300.0, "scrolled into view: {button:?}"); + assert_marks(&marks, button, "scrolled"); +} + +/// A short window scrolls a tool of a toolbar made with New Toolbar into view for a Learn link. +#[test] +fn a_learn_link_scrolls_a_tool_into_view_on_a_toolbar_made_with_new_toolbar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let tools: Vec<&'static str> = slots(&app).into_iter().filter_map(|(_, s)| s.first().copied()).take(16).collect(); + for tool in &tools { + app.run("toolbar.addTool", json!({"tool": tool, "toolbar": "Mine"})).unwrap(); + } + let tool = *tools.last().expect("a tool"); + app.run("toolbar.removeTool", json!({"tool": tool})).unwrap(); + assert!(slots(&app).iter().all(|(_, s)| !s.contains(&tool)), "the Tools panel does not hold {tool}"); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + let (_, nodes) = locate_frame(&mut app, &ctx, 0.05, vec![], 300.0); + let name = tool_info(tool).map(tip).unwrap_or_default(); + let shown = nodes.iter().filter(|n| n.label().is_some_and(|l| l == name)).filter_map(|n| n.bounds()).any(|b| b.y1 <= 300.0); + assert!(!shown, "{tool} starts outside the window"); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap(), json!({"tool": tool, "location": "toolbar", "toolbar": "Mine"})); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let button = tool_rect(&nodes, tool); + let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).unwrap(); + assert!(bar.contains_rect(button), "{button:?} in {bar:?}"); + assert!(button.top() >= 0.0 && button.bottom() <= 300.0, "scrolled into view: {button:?}"); + assert_marks(&marks, button, "scrolled on the toolbar Mine"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// In a short window, the flyout of the last group stays inside the window, so the located row's +/// mark shows. +#[test] +fn a_learn_link_in_a_short_window_marks_a_flyout_row_inside_the_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = reader_context(); + let slot = slots(&app).into_iter().map(|(_, s)| s).rfind(|s| s.len() > 1).expect("a group"); + let tool = *slot.last().expect("a tool"); + locate_frame(&mut app, &ctx, 0.0, vec![], 300.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 300.0); + } + let (marks, nodes) = drawn; + let row = tool_rect(&nodes, tool); + assert!(row.top() >= 0.0 && row.bottom() <= 300.0, "{tool}: the row is inside the window: {row:?}"); + assert_marks(&marks, row, tool); +} + +/// A group with more rows than the window holds scrolls its flyout, and a Learn link scrolls the +/// located row into view before marking it. +#[test] +fn a_learn_link_scrolls_the_row_of_a_group_taller_than_the_window_into_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let tools: Vec<&'static str> = slots(&app).into_iter().filter_map(|(_, s)| s.first().copied()).skip(1).take(16).collect(); + for tool in &tools { + app.run("toolbar.addTool", json!({"tool": tool, "slot": 0, "place": "group"})).unwrap(); + } + let tool = *tools.last().expect("a tool"); + let group = slots(&app).into_iter().map(|(_, s)| s).find(|s| s.contains(&tool)).expect("the group"); + assert!(group.len() * 30 > 400, "{} rows of 30 points do not fit in 400", group.len()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 400.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 400.0); + } + let (marks, nodes) = drawn; + let row = tool_rect(&nodes, tool); + assert!(row.top() >= 0.0 && row.bottom() <= 400.0, "{tool}: the row is inside the window: {row:?}"); + assert_marks(&marks, row, tool); + assert_eq!(app.session.tool_id(), "selection"); + // The mark is clipped to the rows' view inside the flyout's 1 pt border, so a row scrolled + // part way out shows no mark over the border above or below the rows. + let view = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows").shrink(1.0); + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))); + let out = ctx.run_ui(egui::RawInput { time: Some(2.0), screen_rect, ..Default::default() }, |ui| show(&mut app, ui)); + let color = Tokens::get(&ctx).selection; + let clips: Vec = out + .shapes + .iter() + .filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.stroke.width == MARK_WIDTH && r.stroke.color == color)) + .map(|c| c.clip_rect) + .collect(); + assert!( + !clips.is_empty() && clips.iter().all(|c| c.top() >= view.top() && c.bottom() <= view.bottom()), + "the mark is clipped to {view:?}: {clips:?}" + ); +} + +/// A toolbar made with New Toolbar under another floating toolbar comes to the front for a Learn +/// link, so its marked button shows. +#[test] +fn a_learn_link_brings_a_covered_toolbar_made_with_new_toolbar_to_the_front() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for (name, tool) in [("Mine", "artboard"), ("Other", "slice")] { + app.run("window.toolbar.new", json!({"name": name})).unwrap(); + app.run("toolbar.addTool", json!({"tool": tool, "toolbar": name})).unwrap(); + } + for bar in &mut app.ui.custom_toolbars { + bar.pos = [200.0, 200.0]; + } + let (mine, other) = (crate::custom_toolbars::area_id("Mine"), crate::custom_toolbars::area_id("Other")); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, other)); + let (_, nodes) = locate_frame(&mut app, &ctx, 0.05, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(other), "Other covers the button of Mine"); + assert_eq!( + app.run("learn.locateTool", json!({"tool": "artboard"})).unwrap(), + json!({"tool": "artboard", "location": "toolbar", "toolbar": "Mine"}) + ); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(mine), "Mine comes to the front"); + assert_marks(&marks, button, "the toolbar Mine"); +} + +/// A second Learn link moves the mark; Escape removes it. +#[test] +fn a_second_learn_link_replaces_the_mark_and_escape_removes_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + app.run("learn.locateTool", json!({"tool": "pen"})).unwrap(); + let (marks, nodes) = locate_frame(&mut app, &ctx, 0.5, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "pen"), "pen"); + app.run("learn.locateTool", json!({"tool": "curvature"})).unwrap(); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + assert_marks(&marks, tool_rect(&nodes, "curvature"), "the second link"); + let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + locate_frame(&mut app, &ctx, 1.5, vec![escape], 1200.0); + assert!(app.ui.learn_locate.is_none()); + assert!(locate_frame(&mut app, &ctx, 2.0, vec![], 1200.0).0.is_empty()); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 46fde93aad..826e7e02f6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -759,6 +759,39 @@ mod gradient_chip_tests { } } + /// A gradient that repeats or reflects as many times as a chip has cells (8 on a 24-point chip) + /// still shows its runs: the grid gets more cells than runs, so its points do not all sample + /// the same place in a run. + #[test] + fn a_chip_repeated_once_a_cell_is_not_one_color() { + for spread in [vectorcraft_color::GradientSpread::Repeat, vectorcraft_color::GradientSpread::Reflect] { + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = spread; + gp.gradient.repeats = 8; + let (cols, rows) = chips::chip_grid(&gp.gradient, 24.0, 24.0); + assert!(cols > 8 && rows > 8, "{spread:?}: {cols} x {rows}"); + let chip = gradient_chip_colors(&gp, 24.0, 24.0, cols, rows); + let row: std::collections::HashSet = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{spread:?}: {row:?}"); + } + // A gradient that pads keeps a cell about every 3 points. + assert_eq!(chips::chip_grid(&Gradient::default(), 24.0, 24.0), (8, 8)); + // Many runs give a small chip at most a cell a point. + let mut many = paint(GradientKind::Linear, 0.0).gradient; + many.spread = vectorcraft_color::GradientSpread::Repeat; + many.repeats = 100; + assert_eq!(chips::chip_grid(&many, 24.0, 12.0), (24, 12)); + assert_eq!(chips::chip_grid(&many, 400.0, 400.0), (128, 128)); + // A count that matches a chip's points still shows its runs. + let mut gp = paint(GradientKind::Linear, 0.0); + gp.gradient.spread = vectorcraft_color::GradientSpread::Repeat; + gp.gradient.repeats = 23; + let (cols, rows) = chips::chip_grid(&gp.gradient, 23.0, 23.0); + let chip = gradient_chip_colors(&gp, 23.0, 23.0, cols, rows); + let row: std::collections::HashSet = chip.iter().take(cols + 1).copied().collect(); + assert!(row.len() > 2, "{cols}: {row:?}"); + } + #[test] fn radial_chip_runs_from_the_centre_out() { let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4); diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 017ba588ce..1b814728a1 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -33,11 +33,7 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient if !(rect.width() > 0.0 && rect.height() > 0.0) { return; } - // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). - let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; - let max = if freeform { 16 } else { 64 }; - let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); - let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let (cols, rows) = chip_grid(&gradient.gradient, rect.width(), rect.height()); let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); // Stops below full opacity show a checkerboard through them. if colors.iter().any(|c| c.a() < u8::MAX) { @@ -59,6 +55,38 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient ui.painter().add(egui::Shape::mesh(mesh)); } +/// Most grid cells across (and down) a chip of a gradient that repeats or reflects. +const SPREAD_CHIP_CELLS: usize = 128; + +/// The grid (columns, rows) a `width` x `height` chip of gradient `g` is sampled on: a cell about +/// every 3 points (at most 64 a side, 16 for a freeform gradient, whose sampling is costly per +/// spot; the mesh interpolates between them), and for a gradient that repeats or reflects at +/// least 4 cells a run along each side (at most one a point, and [`SPREAD_CHIP_CELLS`]), so the +/// grid's points do not all fall at the same place in a run and paint the chip one color. For +/// such a gradient a side gets a cell or two more when its points would fall at fewer than three +/// places in a run. +pub(super) fn chip_grid(g: &vectorcraft_color::Gradient, width: f32, height: f32) -> (usize, usize) { + let freeform = g.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let runs = if g.is_spread() { g.repeat_count() as usize } else { 1 }; + let least = if runs > 1 { (4 * runs).min(SPREAD_CHIP_CELLS) } else { 1 }; + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + let side = |len: f32| { + let mut cells = ((len / 3.0).ceil() as usize).clamp(1, max).max(least.min(len.ceil() as usize)); + // Points `i / cells` of the way along fall at `cells / gcd(cells, runs)` places in a run. + while runs > 1 && cells / gcd(cells, runs) < 3 { + cells += 1; + } + cells + }; + (side(width), side(height)) +} + /// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side /// (3 to 8 points). fn checkerboard(ui: &Ui, rect: Rect) { @@ -111,7 +139,7 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: } (0..n) .map(|k| { - let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; + let t = gp.gradient.spread_param(geom.param_at(gp.gradient.kind, at(k))).clamp(0.0, 1.0) as f32; rgba(gp.gradient.sample(t)) }) .collect() diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index 1564025a41..f130795c0f 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -4,12 +4,13 @@ use super::{Paint, Rect, Response, Stroke, StrokeKind, Tokens, Ui, Vec2}; /// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. #[derive(Clone, Debug, PartialEq)] pub enum PanelDrag { - /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it - /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, - /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the - /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes - /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is - /// (library id, colour name), which that panel moves to where it is dropped. + /// A paint: Swatches panel tiles, a Fill/Stroke proxy, a Recent Colors chip or the Gradient + /// panel's thumbnail. Art it is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; + /// null for a colour group, which paints nothing) through the active proxy's + /// `paint.setFill`/`paint.setStroke`; the Gradient panel's ramp takes its colour as a stop; the + /// Swatches panel moves `rows`, or makes a swatch of a paint dragged from elsewhere. `library`: + /// the Libraries panel colour it is (library id, colour name), which that panel moves to where + /// it is dropped. Paint { paint: Paint, params: serde_json::Value, rows: Option, library: Option<(String, String)> }, /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. @@ -68,9 +69,9 @@ pub struct SwatchRows { } impl PanelDrag { - /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient - /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, - /// coloured like the dragged ones). + /// `paint` dragged from a Fill/Stroke proxy, a Recent Colors chip or the Gradient panel's + /// thumbnail. A gradient carries no placement: it fits the art it lands on (freeform points are + /// placed afresh there, coloured like the dragged ones). pub fn paint(mut paint: Paint) -> Self { if let Paint::Gradient(g) = &mut paint { g.geom = None; diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index bf25ce58bd..3eec21b928 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -302,13 +302,29 @@ fn step_value( } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is // left of a notch carries over to the next turn. - let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); + let (options, notch_id) = (ui.ctx().options(|o| o.input_options), resp.id.with("wheel")); + let line = options.line_scroll_speed; let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; + let tail_key = wheel_tail_id(ui.ctx().viewport_id()); + let mut tail: WheelTail = if wheel { ui.data(|d| d.get_temp(tail_key)).unwrap_or_default() } else { WheelTail::default() }; + let pass = ui.ctx().cumulative_pass_nr(); + let stored: Option<(u64, Vec<(Event, egui::Vec2)>)> = if wheel { ui.data(|d| d.get_temp(wheel_turns_id(tail_key))) } else { None }; + let mut measured = stored.filter(|(at, _)| *at == pass).map(|(_, turns)| turns); + let mut turns = Vec::new(); + let mut took = egui::Vec2::ZERO; // Each press and each turn of whole notches in the order they came: its modifiers and size from // [`STEPS`], and how many steps up (down when negative). let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); let mut taken = taken; ui.input_mut(|i| { + let (page, dt) = (i.viewport_rect().height(), i.stable_dt); + // In a pass [`WheelTailPlugin`] did not run in (it is added in this one), the field + // measures the pass's turns itself. + turns = match measured.take() { + Some(turns) => turns, + None if wheel => wheel_turns(&mut tail, &i.events, &options, page, i.time), + None => Vec::new(), + }; i.events.retain(|e| { // Each taken event is matched once by its value, so wheel events that other code adds // or removes before the field runs do not shift which events the field takes. @@ -318,7 +334,15 @@ fn step_value( let n = match e { Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, - Event::MouseWheel { unit, delta, .. } if mine => { + Event::MouseWheel { unit, delta, phase, .. } if mine => { + if *phase == egui::TouchPhase::Move { + // Each turn of the pass was measured in the order egui read them; a turn + // other code added after that falls back on the touch state. + took += match turns.iter().position(|(t, _)| t == e) { + Some(at) => turns.remove(at).1, + None => unsmoothed_points(&options, page, tail.touch, *unit, *delta, *modifiers), + }; + } // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; notches += match unit { @@ -340,9 +364,16 @@ fn step_value( // The rest of a turn egui spreads over the next frames scrolls nothing either. i.smooth_scroll_delta = egui::Vec2::ZERO; } + // What egui has not let out of the turns taken here yet stays out of scrolling after the + // pointer leaves the field ([`WheelTailPlugin`]). + tail.owed += took - tail_release(took, dt); }); if wheel { - ui.data_mut(|d| d.insert_temp(notch_id, notches)); + ui.data_mut(|d| { + d.insert_temp(notch_id, notches); + d.insert_temp(tail_key, tail); + d.insert_temp(wheel_turns_id(tail_key), (pass, turns)); + }); } if steps.is_empty() { return None; @@ -442,6 +473,9 @@ fn number_wheel_turns(ui: &Ui, resp: &Response) -> (Vec, bool) { Some((state.walk, state.before.clone())) }); let walked = fresh.map(|(walk, before)| { + if ctx.with_plugin(|_: &mut WheelTailPlugin| ()).is_none() { + ctx.add_plugin(WheelTailPlugin); + } let (events, now) = ui.input(|i| (i.events.clone(), i.time)); walk_wheel(ctx, walk, &events, &before, now, pass) }); @@ -557,6 +591,137 @@ fn walk_wheel( (walk, taken) } +/// The wheel turns that number fields took in one viewport and egui has not let out yet (points), +/// whether a trackpad touch is running there, and the time and source of the last wheel event. +#[derive(Clone, Copy, Default)] +struct WheelTail { + owed: egui::Vec2, + touch: bool, + last: f64, + trackpad: bool, +} + +impl WheelTail { + /// Ends a touch that egui ends at `time` because no wheel event came for a while (its + /// `WheelState::is_scroll_action_over`): 1 s for a trackpad, 0.15 s for any other source. + fn expire_touch(&mut self, time: f64) { + let timeout = if self.trackpad { 1.0 } else { 0.150 }; + if self.touch && timeout < time - self.last { + self.touch = false; + } + } +} + +fn wheel_tail_id(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique("number-field-wheel-tail").with(viewport) +} + +/// Where the wheel turns of a pass are kept with their [`unsmoothed_points`] and the pass number, +/// from [`wheel_turns`]. +fn wheel_turns_id(tail: egui::Id) -> egui::Id { + tail.with("turns") +} + +/// Reads the wheel events of a pass at `time` in order, as egui does, into `tail`'s touch state +/// (a start begins a touch; an end or cancel ends it and drops what is owed; a touch with no event +/// for a while ends), and returns each turn with its [`unsmoothed_points`] at that point. +fn wheel_turns(tail: &mut WheelTail, events: &[egui::Event], options: &egui::InputOptions, page: f32, time: f64) -> Vec<(egui::Event, egui::Vec2)> { + let mut turns = Vec::new(); + for event in events { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source } = event { + tail.expire_touch(time); + tail.last = time; + tail.trackpad = *source == egui::MouseWheelSource::Trackpad; + match phase { + egui::TouchPhase::Start => tail.touch = true, + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + tail.touch = false; + tail.owed = egui::Vec2::ZERO; + // egui drops what the pass's earlier turns left as well. + turns.iter_mut().for_each(|(_, points)| *points = egui::Vec2::ZERO); + } + egui::TouchPhase::Move => turns.push((event.clone(), unsmoothed_points(options, page, tail.touch, *unit, *delta, *modifiers))), + } + } + } + tail.expire_touch(time); + turns +} + +/// The points of wheel turn `delta` in `unit` that egui spreads over the next passes instead of +/// scrolling at once (its `WheelState::on_wheel_event`): a turn sideways with the horizontal +/// scroll modifier, up and down with the vertical one, and nothing for a zoom turn, a touch or a +/// turn under 8 points. `page` is the viewport's height. `modifiers` are the turn's own: egui keeps a +/// touch's modifiers only inside the touch, where nothing is spread out. +fn unsmoothed_points( + options: &egui::InputOptions, + page: f32, + touch: bool, + unit: egui::MouseWheelUnit, + delta: egui::Vec2, + modifiers: egui::Modifiers, +) -> egui::Vec2 { + let mut d = match unit { + egui::MouseWheelUnit::Point => delta, + egui::MouseWheelUnit::Line => options.line_scroll_speed * delta, + egui::MouseWheelUnit::Page => page * delta, + }; + let (sideways, upright) = (modifiers.matches_any(options.horizontal_scroll_modifier), modifiers.matches_any(options.vertical_scroll_modifier)); + if sideways && !upright { + d = egui::vec2(d.x + d.y, 0.0); + } + if upright && !sideways { + d = egui::vec2(0.0, d.x + d.y); + } + let smooth = touch || unit == egui::MouseWheelUnit::Point && d.length() < 8.0; + if smooth || modifiers.matches_any(options.zoom_modifier) { egui::Vec2::ZERO } else { d } +} + +/// What egui lets out in a pass `dt` seconds long of `owed` points of wheel input it has not let +/// out yet (its `WheelState::after_events`): all of an axis under a point, otherwise the share that +/// lets out 90% in 0.1 s. +fn tail_release(owed: egui::Vec2, dt: f32) -> egui::Vec2 { + let t = egui::emath::exponential_smooth_factor(0.90, 0.1, dt.min(0.1)); + let axis = |v: f32| if v.abs() < 1.0 { v } else { t * v }; + egui::vec2(axis(owed.x), axis(owed.y)) +} + +/// Takes out of each pass's scrolling what egui lets out of the wheel turns number fields took +/// ([`WheelTail`]), before any panel scrolls, so the rest of a turn a field took scrolls nothing +/// after the pointer leaves the field. A turn's end or cancel drops what is left. It also measures +/// each wheel turn of the pass with the touch state egui had at that turn, for [`step_value`]. +#[derive(Default)] +struct WheelTailPlugin; + +impl egui::Plugin for WheelTailPlugin { + fn debug_name(&self) -> &'static str { + "number field wheel tail" + } + + fn on_begin_pass(&mut self, ui: &mut Ui) { + let key = wheel_tail_id(ui.ctx().viewport_id()); + let (options, pass) = (ui.ctx().options(|o| o.input_options), ui.ctx().cumulative_pass_nr()); + let mut tail = ui.data(|d| d.get_temp::(key)).unwrap_or_default(); + let mut turns = Vec::new(); + ui.input_mut(|i| { + turns = wheel_turns(&mut tail, &i.events, &options, i.viewport_rect().height(), i.time); + if tail.owed == egui::Vec2::ZERO { + return; + } + let release = tail_release(tail.owed, i.stable_dt); + tail.owed -= release; + // A zoom turn takes what egui lets out, so nothing of it reaches scrolling. + if i.smooth_scroll_delta != egui::Vec2::ZERO || i.zoom_delta() == 1.0 { + i.smooth_scroll_delta -= release; + } + }); + ui.data_mut(|d| { + d.insert_temp(key, tail); + d.insert_temp(wheel_turns_id(key), (pass, turns)); + }); + } +} + /// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter /// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A /// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a0b62da973..2288a160bd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-11-07 · **Change:** text style commands take a feature with a value (`"features": ["salt=2"]`), the font's alternate number, and shape runs that differ only in features as one piece of text; previously: Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits @@ -102,7 +102,7 @@ the two can still win that race. | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `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` | @@ -148,7 +148,7 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. -Sample Color from Screen: `ui.sampleScreenColor {stroke?, picker?}` shows the system's color sampler (macOS, and browsers with an EyeDropper) and returns `null` at once. When the person at the computer clicks a color anywhere on the screen, it runs `paint.setFill` (or `paint.setStroke`) with that color as one undo step, or with `picker: true` sets the open Color Picker's `color`; Escape cancels it and the colors stay as they were. The sampled display sRGB color is converted into the working RGB space. `ui.inspect` reports `screenSampler: true` until the sampler answers. Without a sampler (Windows, Linux, other browsers) the call returns an error. To read a color from VectorCraft's own window without a person, call `ui.screenshot`. +Sample Color from Screen: `ui.sampleScreenColor {stroke?, picker?}` shows the system's color sampler (macOS; Linux through the XDG desktop portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html); browsers with an EyeDropper) and returns `null` at once. When the person at the computer clicks a color anywhere on the screen, it runs `paint.setFill` (or `paint.setStroke`) with that color as one undo step, or with `picker: true` sets the open Color Picker's `color`; a cancel (Escape on macOS and in browsers) leaves the colors as they were. The sampled display sRGB color is converted into the working RGB space. `ui.inspect` reports `screenSampler: true` until the sampler answers. On Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request in another way each put a message in the status bar and end the wait, so the sampler can be shown again. A portal that returns from the call and then neither answers nor leaves the bus keeps the sampler open. Without a sampler (Windows, FreeBSD, DragonFly BSD, NetBSD, OpenBSD, other browsers) the call returns an error. To read a color from VectorCraft's own window without a person, call `ui.screenshot`. Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and `ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the @@ -992,6 +992,23 @@ handle); a Mesh tool click inside a mesh envelope adds a row and a column. While selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command. +## OpenType feature values in text commands + +`text.setRangeStyle`, `text.setStyle` and the character style commands take OpenType features as a list of tags in +`features` (the whole list) or `featureChanges` (changes on top of each character's own): `"dlig"` on, `"-liga"` off. +A feature with alternates (`swsh`, `ss01`–`ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, `subs`, `numr`, `dnom`, +`aalt`) also takes `tag=N`, `N` from 0 to 65535, the font's alternate number: `"salt=2"` draws the second alternate of +each glyph in the range that has one. It is the value the shaper gets (an OpenType alternate substitution picks the +glyph at that position of its set), so `"salt"` is `"salt=1"`, and `"-salt"`, `"salt=0"` and a missing tag are off. A +later value replaces an earlier one; `salt=x`, `salt=65536`, `-salt=2` and `liga=2` are refused. The style's +`features` list and the saved file keep the string as typed (`["salt=2"]`), and SVG export writes CSS +`font-feature-settings: "salt" 2`. Runs that differ only in `features` are shaped as one piece of text with each run's +features over its own characters, so one letter of an Arabic word with `salt=2` keeps its neighbours' joining forms. + +```json +{"id":1,"method":"engine.execute","params":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +``` + ## Shared panel docking The canvas and the panels form one craft-ui docking layout: the protected `canvas` panel beside a dock at the @@ -1087,6 +1104,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Documented OpenType feature values in text commands (`"salt=2"`, the font's alternate number) and that runs differing only in features shape as one piece of text. | | 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | | 2026-10-25 | minor | Documented that Object → Expand… is dimmed, and `ui.expandDialog` refuses, while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089). | diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 6b14fa0907..e219ce5e1b 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 ([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)); no materials, 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)); no materials, 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 7a59146afa..a9880a37dd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,8 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (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-12 · **Change:** minor (`.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). Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -20,8 +22,6 @@ Ordinary File > Save offers Save As when its own location refuses writes with a | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | -| Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -56,7 +56,10 @@ private editing data. VectorCraft: own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. Type from the text document keeps auto leading as auto, each line taking the paragraph's share (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), - and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or + stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names + an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file + doesn't define, or a style that isn't a pattern, keeps the colour as before. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -64,6 +67,7 @@ private editing data. VectorCraft: open with different, unconverted RGB colours than before. A PDF-compatible `.ai` opens on its PDF part's trim boxes (its artboards), the bleed boxes round them becoming the document's bleed, so art in the bleed shows outside the artboard, within the bleed guide (#1266). + With editing data explicitly requested, a single artboard larger than its 14,400 pt capped PDF page opens at its full size, with a warning (#6865). A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way. The swatches of a version 7 `.ai` palette, which carry no names, come in named by their spot color, gradient or values. @@ -110,7 +114,17 @@ private editing data. VectorCraft: Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills, with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024 bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's - styles, with a warning. No art style is written on a large canvas. + styles, with a warning. No art style is written on a large canvas. The Brushes panel's Calligraphic brushes, and its + Scatter and Art brushes with their art, are written in its order: Scatter and Art brushes in Tints or keeping their + art's colors, Art brushes that stretch their art along the path from left to right at 100% unflipped without + overlap, and art of paths and compound paths with points and groups with members, with one solid fill and one + plain stroke each. Other brushes, + Pattern and Bristle brushes among them, and brushes past the number or the art VectorCraft's reader reads are left + out with a warning; no brush is written on a large canvas. A path or compound path with a Calligraphic brush, or + with an Art brush in Tints whose art bends along the path, keeps the brush on its stroke; other brushed strokes are + written as their brush's art. A document that uses a new document's brushes writes none until a path keeps one on + its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle brushes + and its Art brush with other options. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, @@ -238,42 +252,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. @@ -290,9 +269,9 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | -| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | +| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | @@ -343,6 +322,9 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | diff --git a/docs/gaps.md b/docs/gaps.md index 85081869e2..782d6a27b8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#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-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) ## Feature submission evidence @@ -8,7 +8,7 @@ Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged. @@ -39,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 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. | | 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | +| 2026-11-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | @@ -195,6 +196,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **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. +- **Narrowed (2026-11-12):** named CIDFonts over predefined CMaps other than Unicode CMaps (Shift-JIS, EUC, GBK, Big Five, UHC) are set as type too, vertical over `-V` CMaps, also by `charpath`, `cshow`, `kshow` and `glyphshow`. This narrows G9; scores and estimates unchanged. - **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. @@ -212,6 +214,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. +- **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form @@ -357,8 +360,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Patterns and Scale Strokes & Effects: off, Transform Objects leaves the geometry in place; the other two scale stroke weights, dashes and the fills' and strokes' effect distances and move pattern tiles with the object, copy `k` by the transformation `k` times. Canvas, export and Expand Appearance agree. A Transform effect in a fill's or - stroke's own effect list applies the two options to that fill or stroke and its later effects in the same way. - Not yet: object effects after it keep their distances. + stroke's own effect list applies the two options to that fill or stroke; the effects listed after it keep their + distances and apply to the transformed art, as an object's later effects do. - **Done (#937, reproduced case):** Shape Builder regions on art of more than 4,096 segments (traced or type outlines): the planar map no longer gives up there, so lines cut such art instead of the tool offering whole shapes (the planar map's edge-source and face lookups were quadratic in the segments, now indexed, and the cap is 65,536; Divide with open paths shares it). The edge-source index files a long segment under the grid cells along it, so a diagonal hatch (every line's bounds hold most points) no longer costs a scan of the hatch per edge. The grid holds a budget of references (64 per item plus four rows' or columns' worth), the items past it scanned, so coincident segments can't make it quadratic in memory. @@ -384,6 +387,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G4 Advanced type +- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and a glyph no feature reaches whose name does not belong to another letter (`A.salt`, `s.ss1` in a font with no such feature) is applied to the selected character as a glyph choice, which may not join; any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged. + - **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged. - **Done:** character-style Auto-leading resets propagate to style users while retaining local leading overrides. Sparse definitions remain sparse, native serialization retains explicit Auto, and the Character Styles override marker stays consistent. @@ -414,9 +419,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** COLR colour fonts (v0 and v1): a glyph's layers are vector art (solid colours, linear and radial gradients, clips, blend modes, palette 0, the foreground colour from the run's fill), placed with the type, drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF, and editable - paths with Create Outlines. Limits: glyphs using sweep gradients, Porter-Duff compositing, radial gradients from a - circle that isn't the centre, or repeating gradients are images of the glyph until documents have those paints; - estimate unchanged. + paths with Create Outlines. Limits: glyphs using sweep gradients, Porter-Duff compositing or radial gradients from a + circle that isn't the centre are images of the glyph until documents have those paints, and glyphs using repeating + or reflecting gradients are images until the color font reader maps them to a gradient's spread; estimate unchanged. - **Done:** OpenType SVG colour fonts: each glyph's SVG document is read by the SVG importer into vector art, in the run's colours where it uses `currentColor` (or the legacy `context-fill`/`context-stroke`), then drawn, exported and outlined as COLR glyphs are (PDF real text writes them as text that draws nothing, their art drawn where they sit). Images in glyph documents @@ -534,8 +539,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi (#1676). A linked image is refused, as the public documentation has it. - **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. - The original polynomial distributions approximate the public description. On-canvas easing handles - and their synchronization remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + 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 + 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 `gradient`, swatches and the native file) draws a linear or radial gradient fill as pixels with seeded noise of up to a @@ -592,7 +599,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi On Windows with Windows Ink, the pen's tilt and barrel rotation where the pen reports them (#372; not yet tried with a pen). - **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and - barrel rotation on native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen, #372); + barrel rotation on native Wayland and the web, and for Pattern and Bristle brushes (6D Art Pen, #372); the eraser end on native Wayland. - **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify. @@ -610,6 +617,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi them in proportion, as one undo step ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)); on a group (#4939), the groups like it (the same objects at the same places, moved or scaled) take an edit made to an object inside it at the object in the same place ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); + with one such object or group selected with the Selection tool, the objects Start Global Edit would take are + highlighted on the canvas before it starts (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); dynamic symbols (#4938): Symbol Options › Symbol Type (Dynamic Symbol / Static Symbol; `type` on `symbol.new` / `symbol.options` / `symbol.list`), an instance's own fill, stroke, opacity and blend for an object of a dynamic symbol's art (`symbol.instance.setAppearance`, by child-index path), drawn and exported @@ -630,7 +639,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (eight original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one + its own (nine original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams and Dry Media (grainy chalk, pencil, crayon and charcoal art brushes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo @@ -654,8 +663,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; - Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the - start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). + Global Edit of groups holding type or images, and rotated copies as matches; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 5–15 h. ## G3 3D and Materials @@ -858,6 +866,9 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | +| 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | | 2026-11-12 | minor | G11: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); short-drag and jitter-tap regressions in `tests/numfields.rs`; estimates unchanged. | | 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index ce9f55cf50..31a218eb6c 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +22,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | | Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028; Automatic uses XWayland when a pen is attached on a Wayland session, #4967), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac | -| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 3–6 h (#372) | +| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Pattern and Bristle brushes do not use them (an Art brush's Width follows them); 3–6 h (#372) | | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 77086c5031..11de4fbda6 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -87,6 +87,16 @@ pixels and 16,384 pixels on a side. A raster export shows the line as the canvas Type Optimized anti-aliasing, or when it uses Art Optimized and is larger than either limit. Overlapping stacks the colors instead: a lower color continues under the edge of the color above it, and no background shows along that edge. + +**Flat Logo with named colors.** With `logoColors` named, Flat Logo decides whether the image is flat +art by the image's own colors. It finds them the same way as when no colors are named, and looks for as +many colors as are named, with white, or as the Colors setting gives, whichever is more. How far the named colors +are from the image's colors does not change whether the image is refused. While no more colors are +named than the Colors setting, an image is refused exactly when it is refused without named colors. +The trace then uses only the named colors, and white while white is a color of its own. An area of a +color that was not named is traced too: each of its pixels goes to the named color, white or +transparent that has the largest share in the blend of them that comes closest to the pixel. + **Large images.** An image of more than 1500 pixels on a side is traced as any other, only more slowly, and the trace says so: `imageTrace.make` returns the note in `warnings` and the status bar shows it (the [results optimization](https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html) diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 417aab0192..034e009f17 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -126,6 +126,36 @@ flipped crack's, and the image does have cracks the one-sided reading gets wrong 48 px noisy ripple in Black and White at thresholds 140, 157, 170 and Paths 0, 50, 100, sampled on the 4 × 4 grid per pixel the shared-boundary check uses). With the one-sided reading 482–1,228 sample points per trace were uncovered; now none are. The crate's other tests pass unchanged. +## Band borders in smooth areas +In a gradient or a shaded area, the palette colors on either side of a band border differ by more than +the image changes across it. Pixel noise placed the border, and the fitter smoothed it over 0.5 px as it +smooths an edge, so the border wobbled, kept stair steps and took many anchors. For each crack between +two colors, `Layers::step` now reads the share of the step between the two colors that the image makes +next to the crack and a few pixels further out, how far the image changes next to the crack along the +line between the two colors and in any direction, and how much it changes within each side, as RGB +distances (0 to 255 a channel). `subpixel::low_contrast` makes a piece of outline the contour of a smooth +area when, over its cracks, the near share is under one half; the far share is at least 1.25 times the +near one, the change along the line is under 12 (about 7 levels in each channel of a gray), or no crack +has a far share; the sides change within themselves, by at least 1/32 of the change along the line; and the +whole change is under twice the change along the line, or under 24. Black and White traces do not use +the rule. A contour piece is smoothed over 4 px (a closed one over at most an eighth of its smaller width +or height), keeps no corners, is fitted to at least 0.5 px and is checked against its smoothed points. +Grayscale now reads each pixel as its gray for this rule and for every edge's sub-pixel shift +(`Layers::shift`), which read the pixels' colors against gray palette entries before. +Measured with the harness below, before and after the change: Color traces of the "flat colour art" +inputs, and corners % in every row, do not change. Shades of Gray on those inputs: off mean 0.263 to 0.165 px, +off max 1.47 to 1.04 px, anchors 196 to 122. Gradient and photo inputs: anchors 2,138 to 798 (16 Colors, +gradient and photo), 1,372 to 200 (16 Colors, linear gradients), 10,063 to 6,766 (High Fidelity Photo, +gradient and photo); ΔE and edge ΔE change by −0.25 to +0.02. Specks rise by up to 0.8 per image (16 +Colors on gradient and photo 0.0 to 0.8, Auto Color 0.0 to 0.5). Auto Color, enhanced (Shapes on) on +gradient and photo leaves a gap of 0.3 px (was 0.0) and an overlap of 0.4 px (was 0.1). The tables below +are the previous measurement, not a rerun. +Tests on code-drawn images: `tests::the_borders_of_a_gradients_bands_follow_its_contours` (a 256 × 128 +gradient whose contours wave, with noise, and a dark square with crisp sides: the band borders stray +under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, and the square keeps its +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). ## 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 58fc044512..1baefe0ea3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -131,7 +131,9 @@ the suite) keep the agent to the folders you give it: written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error - for that export names the folder by its name alone. + for that export names the folder by its name alone. With Background Export on, an export into the `folder` of + its `exportPreset` runs in the background as one into a `folder` the agent gives, and its replies, errors and + status bar text name that folder the same way. `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as a file) leave out a kept `folder` outside the read roots. `file.package` names the document's file and the linked files outside the read roots by their file names in `warnings`, in @@ -143,24 +145,64 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, + `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, + `destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any + list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that + names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../` + (either separator) that resolves outside the roots from the app's working folder; and the folder + preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is - closed. When another hidden library has the same file name, the libraries take `loaded/<file name>`, - `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session - first, then those only the UI state names), and each one keeps its id in both replies and in the library - commands below. The other entries keep their order, and the app's own state is not changed. + Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` + params of a recorded action's steps in `ui.action_sets`, and the keys of shortcut overrides (`panel:<panel id>` + in `ui.shortcut_overrides` and an open Keyboard Shortcuts dialog's `ui.dialog.fields.overrides`), also when its + panel is closed. A path written with + either separator counts as a path, also one saved on another platform, and outside Windows a path that starts + with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`), and a reply matches it only as written, not with `/` + in place of the `\`. When another hidden library has the same + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they + are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the + libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest + of the session in both replies and in the library commands below, also after another library with the same + file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a + library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next + free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `target`, `before`, `onto`, + `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path + appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its + path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query + (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal + fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + URL writes it, and on Windows in any case of its letters; a `file:` URL of an absolute path is shown as the file + name alone, without `file:` or its host. A hidden id or path stays only where it begins + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it and whether the + request succeeded or failed, so that the reply reads the same either way. The one exception is the `path` + and `files` entries of a reply's result, a `command.batch` step's result included, that name the file the + request wrote inside the write roots at the path the request gave: they stay as + given, as for the save and export replies above. A path param (`path`, `file`, + `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are + matched before the paths the request sent, so a sent path written over part of a hidden library's path does not + keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` + keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, + `pngBase64`, `thumbnailBase64` or `thumbnail` (`library.get`, `document.pdfInfo`), is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered `(2)`, `(3)` and so on as in `ui_inspect` when two have the same file name. That id finds the library in the other library commands (`swatch.library.get`, `swatch.library.add`, `brush.library`, `brush.addFromLibrary`, `symbol.library`, `symbol.addFromLibrary`, `graphicStyle.library`, `graphicStyle.addFromLibrary` and the - others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file - is outside the read roots without naming the file, and it and the library saves with `user: true` leave out - `path` when the user library folder is outside the read roots. + others that take `library`), whose replies show it too. Over the control channel, `window.swatchLibrary` and + the other library panel commands answer with that id and its panel's id (`library:swatches:loaded/<file name>`), + and `ui.libraryPanel` and the `window.panel.*` commands take that panel id in `panel`, `anchor`, `before`, + `onto`, `above` and `below`. `swatch.library.copyToUser` refuses to copy a library file outside the read roots + without naming the file (the swatches of a document loaded from there are written from the loaded library, as + for a library loaded from data, and the document is not read again), and it and the library saves with + `user: true` leave out `path` when the user library folder is outside the read roots. - `links.list`, `links.check`, `links.info` and the `missingLinks`, `modifiedLinks` and `updatedLinks` rows of `document.open` leave out a linked file's path when the file is outside the read roots (a relative path is taken from the document's folder): `path`, `found`, and in `links.info` also `link` and `location`, with the `fileSize` @@ -180,6 +222,14 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. + `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and + leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes` + too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A + stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image + value and a linked JavaScript file in `svg_scripts` outside them by its file name alone (a JavaScript file given + as a URL is kept), and leaves out the `folder` of `export_settings` (the settings Export + for Screens last used) when that folder is outside them. The document itself is not changed. - `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source` @@ -215,6 +265,12 @@ the suite) keep the agent to the folders you give it: `outside the write roots /work/project/out, /tmp/scratch: …`) or `automation filesystem access is not granted: write authority is absent: /work/project/out/x.png`. 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 + 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`. - The preferences that move folders the app reads or writes on its own (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) can only be set inside the roots. The app's own folders (preferences, Data Recovery, the User Defined library folders, VectorCraft's Fonts folder) are not @@ -536,6 +592,19 @@ the panel menu's Show Recent Colors, which hides the Color panel's Recent Colors applied colors still join the list while it is hidden. `ui.colorPanel {"rgbPercent": true}` shows the Color panel's RGB fields in percent, as a double-click right of an RGB field does, and `false` shows them from 0 to 255 again. +In remote desktop mode, `invoke_menu` with `{"command":"ui.sampleScreenColor","params":{}}` shows the system's +screen color sampler and returns `null` before a color is picked: the system's sampler on macOS, the XDG desktop +portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) +on Linux, and the EyeDropper API in browsers that have it. `inspect_ui` reports `screenSampler: true` while it +waits. The picked sRGB color is converted into the working RGB space and becomes the fill or the stroke of the +active proxy in one undo step; `stroke: true` picks the stroke, and `picker: true` sends the color to the open +Color Picker. A cancel leaves the colors as they were. On Linux, a session without a portal that has PickColor +shows "this system has no screen color sampler" in the status bar, a portal that does not return from the call +within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers shows "the screen color sampler +did not answer", and a portal that ends the request in another way shows "the screen color sampler failed"; each +ends the wait, so the sampler can be shown again. Windows, the BSDs and headless sessions have no screen color +sampler. + | Tool | Arguments | Notes | |---|---|---| | `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement, and `schema` and `inputSchema` for a command with a parameter schema (the params doc and both schemas are left out with `brief`). | @@ -550,7 +619,7 @@ RGB fields in percent, as a double-click right of an RGB field does, and `false` | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | -| `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. `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. | +| `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 `<kind>` dialog cannot set protected field `<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`. | @@ -763,7 +832,20 @@ lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian a a warning). `embedFonts: true` embeds the fonts type uses as `@font-face` rules subset to the characters used; a font whose licence (OS/2 `fsType`) forbids subsetting is embedded whole, one that forbids embedding is left out with a warning. Type names its faces by numeric `font-weight` (600 for Semibold and for a Japanese face's W6, 300 for Light, `bold` for 700), -embedded or not. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, +embedded or not. `variableData: true` (SVG Options › Include Variable Data) writes the document's text variables and +data sets as a `variableSets` element in the layout `variable.loadLibrary` reads, so the SVG works as a data merge +template ([help](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/merge-data.html)). +Each text variable names its bound type object by the element's id, `docRef="id('…')"` (VectorCraft's own form); an +unnamed object, or any object with `objectIds: "minimal"`, is given an id for it. A variable bound to several type +objects names the first by object id. Variables of other kinds, text variables whose names aren't XML names, and data +sets whose names are blank, have white space at either end, hold characters XML can't hold or repeat a name are left +out, each with a warning. Only the first 256 text variables and the first 1,000 data sets are written, and variable +data larger than 8 MiB or holding more than 2,000,000 XML nodes is left out (the limits `variable.loadLibrary` +reads), each with a warning. With `outlineText`, the bound type is written as outlines, so its text variables open +unbound (with a warning). `document.open` reads the variable data of an SVG back (unless the SVG reopens from +Preserve Editing): its text variables and data sets, no active data set, and each text variable bound to the type +object its `docRef` names; the art stays as the file draws it. Variable data that `variable.loadLibrary` would refuse +is left out with the warning "the file's variable data wasn't read", and the art opens. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`). Colorized grayscale and 1-bit images export in their fill color; linked mode writes a coverage PNG, and Tiny 1.2 writes a colorized PNG. @@ -787,7 +869,11 @@ a path whose extension names another format has it replaced by the template's extension, and any other path that does not end in that extension has it added, so `card.png` is saved as `card.vctemplate` and `card` as `card.vctemplate`; the suggested name and the Templates folder come back without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options -with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF +with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. +In the replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, `warnings` +lists what the file leaves out of the document or writes differently first. The notes on the options follow: an +option not applied or applied another way while the art reaches the file as it looks, or something an option turns +off, such as `includeImages: false` in DXF. Each kind keeps the order the writers give it. A document saved as SVG or PDF remembers its options, so the next save reuses them; a document not yet saved as a native file is offered the VectorCraft Options the last native Save or Save As used (not its artboards, nor Use Compression). A PDF opened with `pages`, `cropTo` or `password` is only part of the file, so Save asks for a new name instead of writing it back. @@ -816,7 +902,7 @@ symbol (named after its `data-name`, else its id) with an instance per `<use>`; symbol with strokes) stays art. `feGaussianBlur`, `feDropShadow` and the usual shadow chains (offset, blur, flood or colour matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer Glow, Inner Glow and Feather effects; other filters are listed in `warnings`. Undisplayed objects (`display:none`) come back hidden, nested clip paths intersect, -and reflected or repeated gradients (`spreadMethod`) are expanded into stops. `<image>` files (relative links are +and reflected or repeated gradients (`spreadMethod`) import as gradients that repeat or reflect (see Spread under Examples). `<image>` files (relative links are found in the SVG's folder; only files on this computer or the SVG's network share are read, and pasted SVG reads only absolute paths) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that can't be read becomes a placeholder in its box, named in `warnings` and `missingLinks`. @@ -899,7 +985,7 @@ Long-tail commands: PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: -options accepted but not applied yet, and features approximated or left out. An image whose pixel data can't be +features approximated or left out, then options accepted but not applied yet. An image whose pixel data can't be decoded (a PNG cut short) is left out of the pages, and `warnings` holds the note "an image couldn't be decoded and is missing from the picture" once, as raster exports give it; `.ai` saves and `file.print` do the same. A standard with a PDF version it doesn't allow is refused (PDF/A-2b at 1.3 or 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 above 1.4); @@ -1165,6 +1251,49 @@ another form gives a warning: {"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"kind":"diamond","start":[150,150],"end":[200,150],"aspect":60}}}} ``` +Spread (the Gradient panel's menu, beside a Repeats field) says what a linear, radial, conic or diamond gradient +paints past the ends of its stops. `pad` (the default) carries the first and last colors on. `repeat` and `reflect` +run the stops `repeats` times along the vector (1 to 100, VectorCraft's own limit; default 1) and keep going past +both ends of it, `repeat` starting the stops again each time and `reflect` running every other run backwards. A +radial or diamond gradient repeats in rings or squares out from its center (an off-center radial's rings grow about +its focal point), and a conic one repeats around the turn. A freeform gradient has no ends, so a `spread` other +than `pad` is an error for one, and so is a `repeats` that is not a whole number from 1 to 100. Both edit as one +undo step, and an edit that changes nothing records none. A gradient set back to `pad` keeps its count for later, +and it matches a preset or swatch of the same gradient whatever count it keeps. The Gradient tool marks each place +along the bar where one run meets the next (on a conic, across its extent ellipse). A gradient along or across a +stroke runs its stops `repeats` times along or across it, a blend between two gradients that repeat (or reflect) +steps the count from one to the other, and a gradient swatch saved as CSS gets its stops repeated across the box. The +Gradient tool's stop and midpoint markers lie along the whole bar, as on the Gradient panel's slider, and edit the +stops of one run. + +SVG export writes `spreadMethod` with the gradient's vector shortened to one run +(https://www.w3.org/TR/SVG2/pservers.html), and SVG import reads +`spreadMethod` as the spread, with a count of 1. CSS writes `repeating-linear-gradient` and `repeating-radial-gradient` over one run, or +one run and its mirror for `reflect` (https://www.w3.org/TR/css-images-3/#repeating-gradients), and a conic gradient +with its stops repeated around the turn. PDF writes a conic gradient's sweep shading over one run, repeated or +reflected past it. PDF, EPS and legacy `.ai` gradients pad past their ends, so a linear, radial or diamond gradient +is written with its stops repeated as many times as the shape needs: at most 256 runs and 16,384 stops (256 stops in +legacy `.ai`), and for a radial or diamond gradient no farther from its center than 65,536 runs, or than a million +times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning. +The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with +a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that +change its colors least. Each run keeps its ends; sharp steps and swatch-linked stops are the last to be left out, +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 +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 +blurred, and a color band narrower than 1/64 of the axis can be missed when no sample lands in it. Object › Expand +expands the repeated stops, into the same whole number of `steps` objects for each run (at least one, so about +`steps` in all), each run starting on an object's edge; a gradient that needs more than 256 runs across the box stays +a gradient: + +```json +{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}} +``` + Dither (the Gradient panel's box) draws a linear, radial, conic or diamond gradient fill with fine noise that hides banding; SVG and PDF exports write such a fill as an image at the Document Raster Effects resolution. It applies to fills (`stroke: true` is refused): @@ -1403,6 +1532,11 @@ key, the Batch dialog or `ui.action.play`), a key a command no longer reads, rec and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect `color` or `channel` it can't read. +`window.panel` refuses every request in Presentation Mode, which hides every panel, and changes nothing. With +`toggle` it hides only a panel that can be seen, which is the panel the Window menu checks. While Tab or Shift+Tab +hides the dock, no panel is checked, and the panel shows again with the dock in its earlier place; the toolbar and the +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` @@ -1582,7 +1716,9 @@ wrong. A value that is not a number gets `` `dx` must be a number, not "10" ``: `` `size` must be a number, not true ``, and a `tracking` that is neither a number nor a number written as a string (`"20"`) with `` `tracking` must be a number, not false ``. It and `text.setRangeStyle` refuse a `font` or `style` that is not a string with `` `font` must be a string ``, and a `features` list with an entry that is not a string with -`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `type.ruby` refuses a `text`, +`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `text.create`, `text.createInPath`, +`text.setStyle` and `text.setRangeStyle` refuse a `size` outside 0.1 to 1,296 pt with +`` `size` must be from 0.1 to 1296 `` and change nothing. `type.ruby` refuses a `text`, `position` or `align` that is not a string, and `text.setPathHandle` refuses a `which` other than `in` or `out` with `` `which` must be one of in, out ``. Each leaves the document, the selection and the undo history as they were, also when the call gives another param it could apply. A null is read as a key left out. @@ -1907,8 +2043,9 @@ no engines, and the engines it left are dropped when their own thread next uses in the earlier run's script, a file it included or a file its `$.evalFile` ran, and the report's `file` names that file. For an earlier script given as `source`, `file` is that run's name. The report's `name` and the console line's name stay the calling run's, as in `caller.js: /path/lib.jsx:12:5: Error: kept`. 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. + for 8 MiB of source maps, the oldest going first, and a run whose script does not compile keeps none. 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. - `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, @@ -2416,7 +2553,9 @@ each of the others, whose hidden or locked objects are left as they are; when th other group takes its objects at that level, keeping its own ids in order. Whenever the document is put back (Undo, Redo, a cancelled drag or scrub), the matches it no longer has leave the Global Edit, which ends when fewer than two are left. An edit that would take any match past the canvas fails with the coordinates-out-of-range error and changes -nothing. Nothing of it is saved. +nothing. Nothing of it is saved. Before Global Edit starts, while one object that `select.globalEdit` accepts is +selected with the Selection tool, the canvas highlights the other objects a start would take with the current Global +Edit Options (none while a drag is in progress, or when there are none). `select.globalEditExclude {id}` leaves the match `id` out of the Global Edit, as a Shift-click on a highlighted match (or on an object inside it) with the Selection tool does: it is no longer highlighted, and later edits leave it as it @@ -2507,6 +2646,24 @@ and changes nothing. Behavior reference: {"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} ``` +A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the +keyboard focus, runs `learn.locateTool {tool}`, which marks where the tool is in the current layout without choosing +it: its toolbar button, or the tool's row in its group's flyout, which opens when the button shows another tool of the +group. A group torn off into a strip comes to the front with the tool marked in it. A tool that no shown toolbar holds +is marked in the All Tools drawer, which opens; the tool is not added to a toolbar. A Tools panel hidden with +Window › Tools shows again. A target below the window's bottom scrolls into view. The mark lasts 3 seconds after it +first shows; Escape or the next locate removes it. The reply is `{tool, location, toolbar}`: `location` is `toolbar`, +`flyout`, `strip`, `allTools` or `unavailable`, and `toolbar` names the toolbar made with New Toolbar that holds the +tool (null for the Tools panel's). While Presentation Mode or Tab hides the toolbars, the reply is `unavailable` with +a `reason`, and nothing changes. A missing, non-string or unknown `tool` is an error and changes nothing. The mark, +its 3 seconds, the flyout, strip and All Tools behavior and the hidden-toolbar replies are VectorCraft's own rules; +the help page (https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html) +describes selecting a tool's name in a step to find the tool. + +```json +{"name":"invoke_menu","arguments":{"command":"learn.locateTool","params":{"tool":"smooth"}}} +``` + ## Ruler guides Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the @@ -2819,16 +2976,23 @@ already walks Image Trace. Short agent entry points: use version 2, so older builds refuse them instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are - refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is + refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with + less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger + than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is disabled where there is no open dialog (the web). `ui.actionSet.load` also reads the action sets other apps save as text (`.aia`); see "Action sets from other apps". - Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is the top set) to `name` with spaces trimmed from both ends, keeps its steps, function key and color, and returns - `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. + `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. So is a + name that would give the set's action set file more than 8 MiB, counting the steps the recording in progress adds + to the set (`the action set would be larger than 8 MiB`); a name that leaves the file as large or makes it smaller + is never refused. The same holds for `ui.action.options` and for the names Set Options… and New Set… give. - Actions: `ui.action.stepsIncluded {set, action?, step?, included, others?}` includes or excludes all steps of a set, one action, or one step. With `step` and `others: true`, it leaves that step as it was and applies `included` to the - other steps of its action (the panel's Alt/Option-click). An invalid set, action or step changes nothing. The panel's - checkmarks run this command; normal playback and Batch… skip excluded steps. + other steps of its action (the panel's Alt/Option-click). An invalid set, action or step changes nothing. So does an + exclusion that would give the set's action set file more than 8 MiB, counting the steps the recording in progress + adds to the set; it is an error. A change that leaves the file as large or makes it smaller is never refused. The + panel's checkmarks run this command; normal playback and Batch… skip excluded steps. - Actions: `ui.action.new {set, name, key?, cmd?, shift?, color?}` adds an action named `name` (spaces trimmed) after the last action of set `set` (0 is the top set), starts recording into it and returns `{set, action}`; Stop Playing/Recording (`ui.action.stop`) ends the recording. `key` 1 to 15 is the function key F1 to F15 that plays the @@ -2836,12 +3000,23 @@ already walks Image Trace. Short agent entry points: (`red`, `yellow`, `green`, `blue` or `violet`; null or `""` for none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. While an action is recording, and for an index past the last set, - an empty name, or a key or color that is not one of these, it is an error and nothing is added. The panel's Create + an empty name, or a key or color that is not one of these, it is an error and nothing is added. So is an action that + 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. + 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 + 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 `action` in set `set` and keeps its steps: `name` renames it, `key` with `cmd` and `shift` sets its function key, and `color` its Button Mode color, as `ui.action.new` takes them; a param left out keeps what the action has, and @@ -2927,7 +3102,8 @@ already walks Image Trace. Short agent entry points: `ui.actionSet.duplicate {set?: index, action?: index}` copies the action at `action` in set `set` to right after it in the same set, and returns `{set, action}`; without `action` it copies the set with all its actions to right after it, and returns `{set}`. In both cases `set` defaults to the current set. The copy is named after the original with - " copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error. The + " copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error, + and so is a copy that would give its set more than 1,000 actions or a file larger than 8 MiB; then nothing is copied. The panel menu's Duplicate runs this for the selected action, else the current set. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace @@ -3250,6 +3426,28 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}} ``` +## Expand as Wireframes + +`effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces +each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, +Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of +the faces the effect draws for the object as it is turned and projected now +([three-d.md](three-d.md#3d-and-materials-panel)). Each edge appears once; edges that meet end to end form one +path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after a 3D effect, +on the object or on a fill or stroke, reshape its edges as they reshape the drawn surface. The paths have a 0.5 pt +black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name, +visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups +are selected. +A layer in `ids` stays a layer: its members are replaced by the edges, which are selected, and its ruler guides and paths made into guides stay. A path inside a compound path in `ids` stands for the compound path. Objects without a visible 3D effect do not change. It is one undo step and +returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object +has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than +1,000,000 edges. + +```json +{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{}}} +{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{"ids":[12]}}} +``` + ## Extrude & Bevel, Inflate and Rotate `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies @@ -3611,7 +3809,7 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: eight generated in code (Art Ribbons, Calligraphic Nibs, Bristle +Brush libraries are read-only sets of brushes: nine generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and Charcoal Dust art brushes, id `dry-media`), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has @@ -4135,6 +4333,34 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou `arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of 0.25 pt or more) at right angles and spreads twice as wide. +Custom arrowheads come from the arrowheads file, `Arrowheads.vectorcraft` next to the preferences (none on the web or +in runs with `VECTORCRAFT_NO_PREFS`). `stroke.arrowheads.edit` (Stroke panel menu › Edit Arrowheads…) opens it, and +first creates it with one example head when it is missing. Each symbol in the file is one head, named after the +symbol and listed after the built-in heads in both arrowhead menus. These rules are VectorCraft's own: a head points +right, the symbol's registration point is its tip, and the art's height is drawn as wide as the built-in heads (four +head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its +gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is +one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity +masks do not change the shape. The file is read when the app starts and again from disk each time VectorCraft saves it; +`stroke.arrowheads.reload` reads it again. A file larger than 64 MB, or whose document or images unpack to more than 64 MB, is +not read, at most 256 symbols are read, and a symbol that paints nothing, reaches more than 64 head weights from its +tip, has more than 65,536 anchors in its art or in the shapes it paints, has shapes that cannot be combined into one, +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`. + +`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 +refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or +has more than 4,096 anchors, more than 4,096 subpaths or a point more than 64 head units from the tip. The help page +[Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom +arrowheads in a separate arrowheads file. + +```json +{"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"endArrow":{"custom":"Example Arrowhead"}}}} +``` + ## Stroke bounds and clicks Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take @@ -4821,12 +5047,59 @@ They list such a command while the user has changed its shortcut (`overridden: t shortcut for that command runs what a click on the item runs: Object › Path › Clean Up…, Print Ink Fixes… and Edit › Find and Replace… open their dialogs. +A command without a menu path of its own is listed when an item of the menu bar runs it with no params, or when every +item that runs it gives it the same params. It is listed in the group named by the first part of its id. These +commands are listed this way: + +- `addon.pathtools.cornerSet` (Object › Plug-ins › Path Precision Tools › Set Corners…, which opens its dialog), + under Addon; +- `perspective.grid.lock`, `perspective.grid.lockStation`, `perspective.grid.rulers` and `perspective.grid.snap` + (View › Perspective Grid), and `perspective.attach`, `perspective.editText` and `perspective.plane.matchObject` + (Object › Perspective), under Perspective; +- `text.spelling.dictionary` (Edit › Edit Custom Dictionary…), under Text; +- the UI commands `ui.recolorDialog` (Edit › Edit Colors › Recolor Artwork…), `ui.flattenerPresetsDialog`, + `ui.printPresetsDialog`, `ui.pdfPresetsDialog`, `ui.perspectivePresetsDialog`, `ui.svgFilterDialog`, + `ui.saveSwatchLibrary`, `ui.saveBrushLibrary`, `ui.saveGraphicStyleLibrary` and `ui.saveSymbolLibrary`, under Ui; + `file.exportAs` and `file.exportSelection`, under File; `links.editOriginal`, under Links; `type.applyBullets` and + `type.applyNumbers`, under Type; and `window.toolbar.new`, `window.toolbarColumns`, `window.toolsBesideCanvas`, + `window.workspace.new`, `window.arrange.floatInWindow` and `window.arrange.consolidateAllWindows`, under Window. + +When such a command has the same group and label as an engine command that no item of the menu bar runs, the +command the menu item runs keeps the name, and the engine command is listed in the group named by the first part of +its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, and `document.exportSelection` +is listed as Document › Export Selection…. A shortcut already given to `document.exportSelection` keeps running it. +When the other entry is anything else, the command is not listed: `window.learn` is left out, and Window › Learn is +the Learn panel's entry. + +No two rows of Edit › Keyboard Shortcuts have the same English name. Rows that would have the same group and label +are named this way: + +- A command that an item of the menu bar runs, with no params or with the params all its items give, is listed under + that item's menu path: `window.brushLibrary.other`, `window.graphicStyleLibrary.other`, + `window.swatchLibrary.other` and `window.symbolLibrary.other` are listed as Window › Brush Libraries › Other + Library…, Window › Graphic Style Libraries › Other Library…, Window › Swatch Libraries › Other Library… and + Window › Symbol Libraries › Other Library…. +- A command whose id is another command's id with one more part is listed under that command's label: + `ui.charStyleOptions.resetPanel` and `ui.paraStyleOptions.resetPanel` are listed as Character Style Options › + Reset Panel and Paragraph Style Options › Reset Panel. +- The other rows that still have the same name show their id in parentheses after it. The one row of them that an + item of the menu bar runs keeps the name without its id: File › New… is `file.newDialog` and `file.new` is File › + New… (file.new), and Type › Fit Headline is `text.fitHeadline` and `type.fitHeadline` is Type › Fit Headline + (type.fitHeadline). `file.exportInBackground` and `file.openInBackground` are File › Continue in Background + (file.exportInBackground) and File › Continue in Background (file.openInBackground). + +`shortcuts.list` gives such a row's group in `group` and the command's own label in `label`, so the rows that show +their id have the same `group` and `label` there, and `id` tells them apart. `shortcuts.exportText` writes these +names. When a shortcut is already in use, the message names each row that has it by its whole name, group included, +for every row: a `shortcuts.set` error ends with "is already used by Window › Swatch Libraries › Other Library…". + Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:<id>` (the plug-in's id), its -menu label and no default shortcut, so two filters with the same name have two rows. `shortcuts.set {id: -"plugin:<id>", shortcut}`, `shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line -for the row under its menu path and label, and the key runs what a click on the item runs, `plugin.dialog {id}`. A -removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a plug-in -with the same id is installed. +menu label and no default shortcut, so two filters with the same name have two rows. When the two rows have the same +menu path and label, each shows its id after the name. `shortcuts.set {id: "plugin:<id>", shortcut}`, +`shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line for the row under its menu +path and label, with its id when the row shows it, and the key runs what a click on the item runs, `plugin.dialog +{id}`. A removed plug-in's row goes away and its key runs nothing; its shortcut stays saved and applies again when a +plug-in with the same id is installed. `shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line @@ -5052,11 +5325,16 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +96 screen points, an inch at the system's reference density whatever the display scaling). `uiScaling` scales the +interface and not the art: a zoom shows the art at the same size at every UI Scaling. The tool shortcut preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key -repeat chooses the tool. `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features +repeat chooses the tool. The canvas preference `boundingBoxWhileMovingAndDrawing` (off by default) also needs the +desktop app: on, with View › Show Bounding Box, the bounding box and its handles stay visible while a drag moves the +selection, and the Rectangle, Rounded Rectangle, Ellipse, Polygon and Star tools draw the box around the shape being +drawn; off, the box hides during both, except that a Selection tool move with `realTimeDrawing` off still moves the +box. `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Glyphs stays separate). `highlightSubstitutedFonts` (on by default: the canvas paints pink behind text drawn in a substitute for a missing font, in every document). `blackOnScreen` @@ -5421,7 +5699,11 @@ value outside that range is read as the nearer end), `previewMode` and `alignNew `file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets -(mobile, web, video, social) in Pixels. A saved or Recent preset whose size (up to 163,830 pt), `artboards` (1 to +(mobile, web, video, social) in Pixels. `file.new` and `artboard.new` take the same artboard sizes: each side more than 0 +and at most 3,999,856 pt (the 4,000,000 pt the canvas reaches from the origin, less a 72 pt bleed on both sides). A side outside that range is +refused with `width and height must be more than 0 and at most 3999856 pt`, followed by the limit in the document's +units in parentheses when they are not points, such as `(1411.0603 m)`, and no document or artboard is made. +A saved or Recent preset whose size (up to 3,999,856 pt), `artboards` (1 to 1,000), spacing (0 to 100,000 pt) or bleed (0 to 72 pt a side) lies outside those ranges is read with the nearer end of each range, and a size of 0 or less is read as 1 pt. The desktop app's `file.newDialog` (Cmd/Ctrl+N) opens the New Document dialog on the first Recent entry, or on the @@ -6222,12 +6504,16 @@ result carries the same files as a zip (`{name: "<name>.zip", dataBase64}`, entr `document.info {selectionOnly?, category?, format?: "text"}` adds `sections` (`[{id, title, rows: [[label, value]]}]`: document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages, -embeddedImages, repeats), `brushNames` (the Brushes panel's brushes; with `selectionOnly`, the brushes the selected -objects' strokes use), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in +embeddedImages, repeats), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes at an opacity of 0, strokes with no +paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a +shape left out; with `selectionOnly`, the brushes the selected +objects' strokes use), `brushes` (`[{name, type}]`: those brushes with their types, `calligraphic`, `scatter`, `art`, +`pattern` or `bristle`, and `null` for a brush the Brushes panel does not hold; the brushes category's rows give each +name with its type, such as `Art brush`, or `missing from the Brushes panel`), `panelBrushNames` (the Brushes panel's brushes, whether art uses them or not), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes, and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects` (`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects -whose drawn characters use a pattern included, hidden fills and strokes, strokes with no width, guides, a compound path +whose drawn characters use a pattern included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection), @@ -6235,7 +6521,13 @@ the patterns it uses, which the patterns category lists as Pattern Objects and ` stroked with a gradient, by the same rules as `patternObjects`, with the gradients it uses: the name of the gradient swatch a gradient is linked to, else `Linear gradient`, `Radial gradient` or `Freeform gradient`; the gradients category lists them as Gradient Objects and `objects.gradients` counts them; gradient swatches no art uses are not listed; with -`selectionOnly`, those in the selection), `gradientNames` (the gradients those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left +`selectionOnly`, those in the selection), `gradientNames` (the gradients those objects use), `spotObjects` +(`[{id, name, spotColors}]`: every object filled or stroked with a spot color swatch, as a solid color or in the +stops of a gradient with a stop above opacity 0, at a tint above 0, and every grayscale image colorized with one, by +the same rules as `patternObjects`, with the spot colors it uses; a process +color, even one of the same color, and Registration are not spot colors; the spotColors category lists them as Spot +Color Objects and `objects.spots` counts them; spot swatches no art uses are not listed; with `selectionOnly`, those +in the selection), `spotColorNames` and `spotColors` (the spot colors those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left out, which objects lists as Path Length in the document's units, say for a cut line or an LED strip along the letters); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?, selectionOnly?, category?}`: only `category` when given, else every category) and the package report write. @@ -6309,7 +6601,8 @@ the later file is written as `-2`, `-3`… (`Artboard-1-2.png`) and listed under rows write one file, and row folders that differ only in case (`Print`, `print`) are one folder, spelled as the first row spells it. The document remembers the last params: `document.exportSettings`. As in `document.export`, the reply's `warnings` lists the notes of every file exported (an image that couldn't be decoded, type written in the -fallback font…), each note once; the export still writes or returns every file. +fallback font…), each note once, what any of the files loses before the notes on the options; the export still +writes or returns every file. Batch exports (#1935): - **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards @@ -6741,9 +7034,11 @@ says when CSS can't describe the art exactly (`unsupported`: other shapes, image written as its box, or with `rasterize: true` as `background-image: url(<class>.png)`. `units` is `px` (1 px per point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out unnamed objects (counted in `skipped`). `css.generate` does the same for the whole document, and `css.export {path?, scope?: selection|all}` -writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its -`warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the -fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web +writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). A left-out +`scope` exports the selection; a `path` or `scope` given as anything but a string, `null` included, is an error, and +nothing is written. `css.generate` and `css.export {scope: "all"}` do not use `ids` or `id`, but a malformed one is still +an error. Its `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, +type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out. @@ -6891,7 +7186,7 @@ dictionaries (the standard ones are values in `systemdict`; `dictstack`, `intern intervals share storage, resources (categories made from `Generic`, `resourceforall`), executable filters (`cvx exec` runs their data; `flushfile` skips it), axial and radial shadings and shading patterns (gradients), images and image masks (data in the file through ASCII85, hex, run-length, Flate, LZW or DCT filters, or from procedures), and type as -point type in the font the file names (embedded Type 1 and Type 42 fonts' characters and widths come from the font program they carry, and the show operators' spacing along the baseline is kept as tracking, while spacing that moves characters off it (`yshow`, `xyshow`) makes a text object per character; CID-keyed fonts draw the glyphs of the Type 0 and Type 2 CIDFonts the file carries, CIDFonts in a FontSet's Compact Font Format data included). In a file in Illustrator's own format +point type in the font the file names (embedded Type 1 and Type 42 fonts' characters and widths come from the font program they carry, and the show operators' spacing along the baseline is kept as tracking, while spacing that moves characters off it (`yshow`, `xyshow`) makes a text object per character; CID-keyed fonts draw the glyphs of the Type 0 and Type 2 CIDFonts the file carries, CIDFonts in a FontSet's Compact Font Format data included; a CID-keyed font whose CIDFont the file only names, over a predefined CMap such as `UniJIS-UCS2-H`, `90ms-RKSJ-H` or `GBK-EUC-H`, sets the characters its codes stand for as type in the font of the CIDFont's name, as vertical type over a `-V` CMap, and type in a predefined CMap that can't be read is left out with a warning). In a file in Illustrator's own format (Illustrator 3–8 `.ai` and their EPS, written with the prolog that defines its operators) the groups it writes (`u` … `U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. The artboard is the `%%HiResBoundingBox` (else `%%BoundingBox`; a letter page without one). A program the interpreter can't run (an operator it doesn't know, @@ -7426,6 +7721,27 @@ Panel sets it back to Push Out Only. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} ``` +## Bunri-Kinshi + +`text.setFormat {bunriKinshi: bool}` (Paragraph panel menu › Bunri-Kinshi, shown with the East Asian options) turns it +on or off for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / `end` range touches). On, a +line never breaks inside an inseparable pair: two em dashes (U+2014), two horizontal ellipses (U+2026), two two-dot +leaders (U+2025), or the upper half of a vertical kana repeat mark (U+3033 or U+3034) followed by its lower half +(U+3035), the pairs W3C JLREQ lists for its class cl-08 (https://www.w3.org/TR/jlreq/#cl-08), which are VectorCraft's +set. A chain of em dashes stays together as well. A pair is two neighboring characters, whatever their fonts and +styles; a forced line break between them ends the line as before. Both composers, vertical type, Kinsoku Shori Type, +hyphenation and No Break follow it, and a pair wider than the line stays whole on it. Justification adds no space inside +a pair. The setting works only with a Kinsoku Set: with Kinsoku Set None the paragraph keeps the value without effect, +and the menu item is unavailable. Off by default, for new type and for paragraphs saved before the option. It is saved +with the paragraph (`bunri_kinshi` in `para`) only when on, paragraph styles carry it (Paragraph Style Options › East +Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo +step. `.ai` editing data does not hold it, and a save reports a paragraph with it on and a pair in it as left out. The help page: +https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"bunriKinshi":true}}} +``` + ## Korean Line Break Anywhere `text.setFormat {koreanLineBreakAnywhere: bool}` (Paragraph panel menu › Korean Line Break Anywhere, shown with the @@ -7946,20 +8262,23 @@ it, as with any style change to one character (`text.setRangeStyle`). The type s ## Alternate glyphs The Glyphs panel's Show › Alternates for Current Selection lists the alternate glyphs of the character selected with -the Type tool (a range's first character, else the one before the caret), with the default glyph first when the font -does not list it, and a double-click on one draws that character with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, +the Type tool (a range's first character, else the one before the caret) as it stands in its word, with the glyph the +shaper makes for it first, and a double-click on one applies the OpenType feature that draws it to that character alone +(see [Alternates as OpenType feature values](#alternates-as-opentype-feature-values)); a glyph no feature draws there is +drawn by `text.setGlyphAlternate`. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, chosen, alternates: [{gid, feature}]}`, where `default` is the glyph the font's character map gives, `chosen` the glyph drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate substitutions put in place of the default glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`, in lookup order within each, each glyph once. The default glyph is listed only when `aalt` lists it. Ligatures and contextual substitutions are not listed. `text.setGlyphAlternate {id, start, gid}` draws the character with glyph -`gid`, one of those alternates or the default glyph, as one undo step; `gid: null` takes the choice away, and the +`gid`, one of those alternates, a glyph that no character maps and no feature reaches whose name is not another letter's +(`a.salt` for `a`, in a font with no `salt`), or the default glyph, as one undo step; `gid: null` takes the choice away, and the character is drawn as its style draws it (a feature such as `salt` can draw another glyph than the default). The text keeps the character and its other attributes, and characters typed next to it take their default glyphs. The choice is saved with the character's style as `glyphAlternate: {ch, gid, font, version}`, the face's PostScript name and version string: set in another font or version, or with the font missing, the character takes its default glyph. Both commands -refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of the -character's alternates and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral +refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of those +and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral system). Choosing the glyph a character already draws is not an edit. Character styles do not take `glyphAlternate`: `charStyle.new`, `charStyle.edit` and `charStyle.setAttrs` refuse it. @@ -7968,6 +8287,36 @@ system). Choosing the glyph a character already draws is not an edit. Character {"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}} ``` +### Alternates as OpenType feature values + +`text.setGlyphAlternate`'s choice swaps a glyph id into one character and shapes that character apart from its neighbours. +A feature with a value keeps the text real and joined: `features`, `featureChanges` (`text.setRangeStyle`, +`text.setStyle`, `charStyle.*`) take `tag=N` for `swsh`, `ss01` to `ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, +`subs`, `numr`, `dnom` and `aalt`, `N` from 0 to 65535, and the style's `features` list keeps the string: +`"salt=2"` draws the font's second alternate of every glyph in the run that has one (the OpenType alternate +substitution picks the glyph at that position of its set; a glyph without one, or with fewer alternates, keeps the +glyph it has). A plain `"salt"` is `"salt=1"` and is written so; `"salt=0"`, `"-salt"` and leaving it out are off. The +next value for a feature replaces the last one, in `features` and in `featureChanges`, and a tag the build does not know +(`salt=x`, `salt=65536`, `-salt=2`, `liga=2`) is refused with the unknown-feature error. SVG export writes the value as +CSS `font-feature-settings: "salt" 2`. + +Runs that differ only in their `features` are shaped together as one piece of text, each run's features over its own +characters: styling the middle letter of an Arabic word with `salt=2` leaves every other letter its initial, medial or +final form and the letters still join, as the same word typed with no style change. Runs that differ in font, size, +scale, case, language, script or direction are still shaped apart. + +`text.glyphAlternates` lists a character's alternates by the glyph the font's character map gives it; the Glyphs panel +instead lists those of the glyph the shaper makes for the character in its word (a medial letter's medial form), finds +for each the feature value that draws it there, and on a double-click runs one `text.setRangeStyle` on exactly that +character: `featureChanges` turns its other alternate features (`salt`, `aalt`, `swsh`, `ss01`–`ss20`) off and sets the +value (`["-ss01", "salt=2"]`), and `glyphAlternate: null` takes away a glyph choice that would otherwise keep drawing +its own glyph (`text.setRangeStyle` takes it; any other value is refused). It is one undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"featureChanges":["-salt"]}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em @@ -8093,6 +8442,28 @@ stops, the command changes nothing and records no undo step. `text.tabs.set` rej holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and keeps the first of stops at one position. +`text.setFormat {paragraphIndents: [{id, start, leftIndent?, firstLineIndent?}…]}` gives each paragraph indents of its +own in one call: each entry names one paragraph of a type object by the byte `start` where the paragraph begins, and +sets its `leftIndent`, its `firstLineIndent` or both, in points from -1296 to 1296. The call takes no other key, and +at most 100000 entries. It checks every entry before it changes anything, and fails on an empty list, an unknown key, an +entry with neither indent, a value that is not a number in range, an `id` that is not a type object, a `start` where no +paragraph of that object begins, and two entries for one paragraph with other values (the same entry twice counts once). +The whole change is one edit: each type object's paragraph styles are rebuilt once, and a threaded story flows through +its frames again once, however many of its paragraphs change, where one `text.setFormat {ids, start, end}` for each +paragraph flows it again after every paragraph. When no paragraph's indents change, the command records no edit and no +undo step. It returns `{ids}`, the type objects the entries name. The Tabs panel's indent markers use it when the +paragraphs they move have indents of their own, inside a `command.batch` that names the undo step after the marker. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"paragraphIndents":[{"id":12,"start":0,"firstLineIndent":36},{"id":12,"start":40,"leftIndent":24,"firstLineIndent":12}]}}} +``` + +The single-value form of `text.setFormat` fails, naming the key, when a number such as `leftIndent` or `baselineShift` is +given as a string, a switch or a list, or a switch such as `underline` as anything but true or false; it changes nothing +then. A null value of any attribute leaves that attribute as it is, as an omitted one does, so a call whose attributes +are all null fails with "nothing to change" and records no edit and no undo step. A call whose values are the ones the +text already has succeeds and returns its `ids`, but records no edit and no undo step. + `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and @@ -8938,9 +9309,30 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p {"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}} ``` +`object.blend.setEasing {id?, easingType?, ramp?, colorShift?}` sets one blend's easing and color shift (the +first selected blend without `id`) and returns `{id, easingType, ramp, colorShift, changed}`; omitted values are kept. +`easingType` is `easeIn`, `easeOut`, `easeInOut` or `easeOutIn`, `ramp` is 0 to 100 and `colorShift` -100 to 100. +`easingType` and `ramp` need a blend whose steps follow a spine (a replaced or edited spine, or +`orientation: "path"`), as the Blend panel's Type and Ramp do. A blend made without a spine +(`createSpine: "never"`) takes them once Replace Spine has given it one. A value out of range, a hidden or locked blend and a target that is not a blend are errors, +and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, +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: + +```json +{"name":"run_command","arguments":{"command":"object.blend.setEasing","params":{"id":40,"ramp":35}}} +``` + How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes -interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale -and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with +interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale, +width profiles and the pen pressure, tilt, bearing and rotation recorded with the Paintbrush (a stroke without a +recording counts as half pressure, half tilt and 0°, as brushes read it; angles turn the shorter way round; a key's +profiles, recordings and arrowheads turn with its path when the blend starts it at another anchor), while +caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and images interpolate their transforms (type also its colours, stroke weight and size run by run; the words and the @@ -9576,8 +9968,9 @@ it ends up in front), baked with `effect.expandAppearance`, then `object.pathfin ## Cutting Tools add-on -Cutting Tools is a built-in add-on that prepares art for cutting. Its command is `addon.cutting.bridges`; in the desktop -app it is Object › Plug-ins › Cutting Tools › Stencil Bridges…. +Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges` and +`addon.cutting.weed`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil Bridges… and Weed Box and +Lines…. ### Stencil Bridges @@ -9628,6 +10021,44 @@ Crossing) and Keep Original. {"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":3,"count":2,"direction":"shortest","keepOriginal":false}}} ``` +### Weed Box and Lines + +`addon.cutting.weed {margin?, box?, direction?, count?, spacing?, gap?, weight?, swatch?}` adds a weed box around the +selected art and weeding lines across the box, so the waste vinyl around the art lifts off in pieces +([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The ranges, defaults and rules below are +VectorCraft's own. The weed box is a rectangle `margin` points (0 to 100000, default 9) outside the art's bounds; +`box: false` leaves it out. The weeding lines run `"horizontal"`, `"vertical"` or `"both"` ways (default `"both"`) from +one side of the box to the other and divide it evenly: `count` lines each way (a whole number from 0 to 1000, default 1), +or with `spacing` instead, as few lines as keep them at most that far apart. A line is left out over the art's area and +within `gap` points of its outlines (0 to 100000, default 0: the lines stop at the outlines), so it ends in several +pieces, each a straight path. The lines and the box are stroked `weight` points wide (0.01 to 1000, default 0.3) in the +color swatch `swatch` (default `"[Registration]"`), with no fill. + +The art is measured by the outlines a cutter cuts. Closed paths enclose their area by their fill rule, so the hole of a +compound path filled even-odd gets lines too, and open paths are lines with no area. Type is measured by its glyph +outlines, a clip group by what shows inside its clipping path (an open clipping path clips as if closed), an image by +its frame, a placed file by the art it shows (by its box when the file can't be read), a symbol instance by its art (an +instance of a symbol the document doesn't have adds nothing) and a live object such as a blend by the art it draws. An +object selected inside a clip group is measured by what shows of it, and a clipping path selected on its own as its clip +group. Strokes, effects, hidden objects and objects in a hidden group or layer are left out. + +The result is one group named "Weeding" (the box is its member "Weed Box"), added on top of the current layer and +selected, as one undo step. While a clip group, or a group inside one, is edited in isolation, the group goes on top of +the container that holds the outermost clip group around it. With nothing selected, or with a selection that has no +outlines, the command fails. It also fails when the art's outlines, followed in straight segments, have more than +200000 points; when groups, symbol instances and live objects are nested more than 128 levels deep; when measuring the +clip groups' outlines against each other takes more than 40000000 tests of one segment or outline point against another, +counted over every clip group in the selection together and including the outlines a nested clip group hides; when, for the +lines one way, more than 10000000 pairs of a line and an outline segment within `gap` of it are found, or closed +outline segments are checked for crossings with the lines more than 10000000 times; when the lines would be more than +100000 pieces; and when no line fits outside the art while `box` is false. Result: `{id, box, lines}`: the group's id, +the weed box's id (null without one) and the number of pieces of weeding line. Weed Box and Lines… opens the command +dialog with Preview; a Spacing above 0 is used instead of the Count. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.weed","params":{"margin":"0.25 in","direction":"both","spacing":"2 in","gap":1}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is @@ -9944,9 +10375,11 @@ change in one undo step, and an image that `ids` names twice changes once. An im as it is, and when none changes the command adds no undo step and returns `changed: 0`. `cleared` counts the pixels made fully clear in the images that changed. The command refuses, and changes nothing, when an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance while it is -worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), or every pixel of an -image would be cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas -shows the result for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, +worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), an image is +grayscale and colorized with a process color (the result has an alpha channel, and only a spot color, black or white +colorizes a grayscale image with transparency, as `paint.setFill` requires), or every pixel of an image would be +cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas shows the result +for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, the session keeps the decoded pixels of the images it changes (at most 128 MiB, or 32 MiB in the web app), so the next preview step does not decode them again; it releases them when the dialog closes with OK or Cancel. A run that is not a preview step does not keep them. diff --git a/docs/roadmap.md b/docs/roadmap.md index 56230e6da3..4c78d258c9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,8 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; 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 are in; 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; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **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: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; 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 are in; 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; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; 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; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **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). > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -10,11 +12,13 @@ ## Feature submission evidence +M4: Explicitly requested editing data keeps a single artboard larger than its 14,400 pt capped PDF page (#6865). Scores and estimates unchanged. + Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -55,6 +59,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. 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. | +| 2026-11-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | | 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). | @@ -143,13 +148,13 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, eight generated libraries including Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -190,6 +195,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-12 | minor | M7: Glyphs panel applies feature-valued alternates and unencoded glyphs while keeping Arabic joining (#5514). Scores and estimates unchanged. | | 2026-11-12 | minor | M14: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects all (#6659, follow-up to #1326); estimates unchanged. | | 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | diff --git a/docs/shaper.md b/docs/shaper.md index d32659478e..2318edd8a8 100644 --- a/docs/shaper.md +++ b/docs/shaper.md @@ -9,7 +9,7 @@ Scribble over overlapping paths to make an editable Shaper Group: - End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there. - Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately. -Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. +Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. With the group or one of its faces selected, a Shaper click on an original shape's outline also enters construction editing and selects that shape, even where the outline runs through a merged face ([help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). The click counts within 3 screen pixels of the outline, a VectorCraft value. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. While the group or one of its faces is selected (Face Selection mode) and the Shaper or Selection tool is active, an arrow widget sits beside the middle of the right side of the original shapes’ bounds. Click it to enter Construction mode: the original shapes are isolated and nothing is selected, and a click with Shaper selects the group’s original shape under the pointer. Click the widget again to leave Construction mode with the group selected. The arrow points right in Face Selection mode and left in Construction mode. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0921b2ed72..3ef2425203 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,10 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** 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-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: 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). +> +> Glyphs update (#5514): Alternates for Current Selection applies a variant's OpenType feature to that letter alone (`salt=2`) so an Arabic word stays joined; Entire Font lists unencoded glyphs. A positional variant no supported feature draws can be applied as a glyph choice (#6084). Entire Font uses the selection's font version and variable axes, and the default cell remains available after an unlisted choice even without GSUB alternates. Only Arabic presentation-form characters insert their joining base letter. Scores and estimates unchanged. > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -16,7 +20,7 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars stay at the bottom after enlargement. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars stay at the bottom after enlargement. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -65,6 +69,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-07 | minor | Type: paint-only Arabic harakat keep joining forms; a matching character-level pass assigns a later in-cluster run only for GDEF mark glyphs on harakat characters (otherwise cluster start); Insert Kashidas tatweels take the base letter's paint (`tests::harakat`). Scores and estimates unchanged. Marks may retain letter paint without GDEF mark classes or with some AAT/`morx` ligatures. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | +| 2026-11-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | A nearby hit on a plain long stroke or outline avoids assembling its whole path. A hit near the middle of a 200,000-anchor path took about 11–13 ms before and 1.0–1.4 ms after in paired runs on a busy Windows machine; a miss inside the path's box fell from 18.7 ms to 1.6 ms, while a distant miss was unchanged. Other stroke styles and fill hits keep their existing checks. Scores and estimates unchanged; idle performance budgets remain unverified. | | 2026-11-05 | minor | Translating a type object without changing its text or formatting reuses glyph geometry and expanded character-stroke outlines between frames. The text appears at its new position with unchanged pixels. Scaling the type, editing its content or formatting, or changing installed fonts rebuilds the geometry; scores and estimates unchanged. | @@ -220,7 +225,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (eight generated libraries including Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -230,7 +235,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces; materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step` | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | @@ -489,7 +494,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (eight generated in code including Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -503,7 +508,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | 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 | | 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, Smart Guides' angle and distance readouts while drawing, 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) | 2–4; status-bar X/Y uses the active ruler origin and General units | -| File formats | 6 | 85% | 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) | +| File formats | 6 | 85% | 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) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | @@ -541,7 +546,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries eight (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -564,6 +569,7 @@ since 2026-10-01. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. +- 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. @@ -582,6 +588,7 @@ since 2026-10-01. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. +- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -590,15 +597,16 @@ since 2026-10-01. - **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. -- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. +- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. An object's or group's Transform effect changes the effects of its fills, strokes and members only through Scale Strokes & Effects, as before. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **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. - **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. -- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each 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_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. -- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. +- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each 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_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. +- **Roughen at Size 0 keeps the outline:** with Size 0 (relative or absolute), Effect › Distort & Transform › Roughen… moves no point: each segment is split at the Detail spacing (fewer points past the anchor limit) into pieces of equal length on the segment itself, so the path keeps its outline, with Smooth or Corner points alike. A straight segment gets corner points without handles, and a curve gets smooth points whose handles follow it. Before, the points were joined by straight lines (Corner) or a smoothed curve through them (Smooth), which changed curved outlines. Existing documents with a Roughen of Size 0 on a curved path, or with Smooth points, draw differently. This is VectorCraft's own rule; what Roughen does 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 `tests::roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly`). Scores and hours unchanged. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. @@ -651,11 +659,12 @@ since 2026-10-01. - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy `<name> copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>) also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. +- **Letter spacing and Full Justify between shaped clusters:** Desired Letter Spacing on any line, the letter spacing range of a justified line, and Single Word Justification's Full Justify add space only between shaped clusters, in horizontal, right-to-left and vertical type, so a combining mark stays on its letter and a Devanagari syllable's glyphs stay together. They add no space next to a zero-width format control (a soft hyphen, a bidi mark, embedding, override or isolate, a zero-width space, a joiner or non-joiner, a word joiner, a variation selector, a tag, the byte order mark), so a word wrapped in an override still reaches both edges of a justified line, and a Kinsoku push-in counts no gap at one either. A zero-width space is a line break opportunity but not a word separator ([CSS Text Level 3](https://www.w3.org/TR/css-text-3/#word-separator) does not list it), so a justified line gives it no share of its room. Desired Letter Spacing between the letters on either side of a zero-width space goes after the zero-width space, so a line that breaks there ends at its last letter, and the line breaker does not measure a gap after it. A line that Full Justify spreads and that ends in a drawn hyphen puts the same gap between its last letter and the hyphen. The cluster boundary is VectorCraft's own rule, based on the typographic character unit of [CSS Text Level 3](https://www.w3.org/TR/css-text-3/#typographic-character-unit). Tests `tests::justify::single_word_full_justify_keeps_combining_mark_with_base`, `desired_and_fitted_letter_spacing_skip_shaped_cluster`, `rtl_single_word_cluster_gap_follows_drawn_order`, `vertical_single_word_cluster_gap_follows_drawn_order`, `single_word_full_justify_keeps_conjunct_cluster`, `desired_letter_spacing_skips_bidi_format_controls`, `letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens`, `format_controls_are_the_zero_width_default_ignorables`, `zero_width_space_takes_no_word_space_room`, `desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap`, `single_word_full_justify_spreads_up_to_the_drawn_hyphen` and `layout::wrapping_tests::kinsoku_shori_type_counts_no_gap_at_a_format_control`. Public reference: the help page (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>). - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. - **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. -- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). +- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat {paragraphIndents}` with an entry for each paragraph of the story, whatever order the frames were selected in with the Selection tool, so the story flows through its frames again once rather than once for each paragraph, and a drag that changes no paragraph runs nothing; a paragraph that runs on from one frame into the next gets one entry, after Type tool Select All too. A selected story is one text: with the Selection tool, a marker drag on any of its frames sets every paragraph of the story, in its locked and hidden frames too. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_over_many_threaded_paragraphs_send_one_format_command`, `indent_markers_after_select_all_list_a_split_paragraph_once`, `indent_markers_on_some_threaded_frames_list_the_whole_story_once`, `indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames`, `cmd::threads::tests::paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would`, `paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once` and `paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing`, and `cmd::typecmd::area_tests::set_format_refuses_a_param_of_the_wrong_type`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. @@ -725,6 +734,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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: 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, the Object › Align items, 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. @@ -760,16 +770,18 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - 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: 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. `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: `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`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. + - Errors under `--automation-read-root`: a refused save without a path and `file.revert` (also as an `action.batch` step), `script.listInstalled` and `links.relink {copy}` name the document's own file, the Scripts folder or the document's folder by its file name when it is outside the read roots, and keep the roots and the reason; `links.relink`'s copies name such a file by its file name too. A path the client gives stays whole. `crates/engine/src/tests/save.rs` (`pathless_save_rejection_hides_document_folder`), `crates/engine/src/cmd/fileio/action_batch.rs`, `crates/engine/src/tests/links.rs`, `crates/engine/src/tests/script_folder.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_the_read_roots`). - 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`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.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/<file name>` 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/<file name> (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`. - 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`. @@ -820,6 +832,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). + - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). @@ -835,6 +848,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). + - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. @@ -858,7 +872,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. @@ -874,10 +888,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. Under either wheel preference, Cmd/Ctrl with a touchpad zooms about the pointer. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). + - Preferences › User Interface › UI Scaling scales the interface ([help](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/modify-workspaces.html)). VectorCraft's own rule is that it leaves the art's size alone: at a given zoom a document point covers the same device pixels at every UI Scaling, so View › Actual Size shows the art at the same size whatever the UI Scaling. A click, a pinch or wheel zoom about the pointer, a pan, the Zoom tool's marquee, Fit, tiled panes, Pixel Preview and the Navigator's view box use the same mapping. Evidence: `canvas::tests_ui_scaling`, `tiled_documents_fit_their_panes_at_every_ui_scaling`, `the_view_box_outlines_the_canvas_at_every_ui_scaling`. Scores unchanged. - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except User Interface › Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only; [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - With Performance › Real-time Drawing and Editing off, the bounding box moves with a Selection-tool move drag and the object moves when the button is released ([help](https://web.archive.org/web/20220324053041id_/https://helpx.adobe.com/illustrator/using/real-time-editing.html); `real_time_drawing_off_moves_the_bounding_box_with_a_move_drag`). + - VectorCraft's own preference General › Show Bounding Box While Moving and Drawing (`boundingBoxWhileMovingAndDrawing`, off by default): on, with View › Show Bounding Box, the bounding box and its handles stay visible while a drag moves the selection (a Selection tool move, an Alt-drag copy, a shape tool's move by the center point), measured once when the drag starts and moved with it, and the Rectangle, Rounded Rectangle, Ellipse, Polygon and Star tools draw the box around the shape being drawn. A rotation and a widget drag still hide the box until the release, and the Line Segment tool, the Pen, the Pencil and the Paintbrush draw none (`a_move_drag_keeps_the_bounding_box_where_the_drag_carries_the_selection`, `a_shape_being_drawn_shows_its_bounding_box`, `a_move_drag_measures_the_bounding_box_once`). - Performance › Real-time Drawing and Editing off defers a drag's edits to its release, but a ruler guide dragged out of a ruler or moved still shows as it is dragged, where only its position label showed (#4646; `a_guide_dragged_out_of_a_ruler_shows_while_dragged`, `real_time_drawing_off_still_shows_a_dragged_guide`). - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). @@ -897,6 +913,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. + - While Tab or Shift+Tab hides the panels, the Window menu checks no panel, and choosing a panel there, or pressing its function key, shows the dock with that panel in its earlier place instead of closing it; the toolbar and the Control bar stay as they are. In Presentation Mode no panel is checked, and `window.panel` fails and changes nothing. A tool double-click that opens the Gradient or Magic Wand panel and a Character field shortcut also show the hidden dock (`window_panel_from_tab_hidden_dock_reveals_named_front_tab`, `window_panel_from_shift_tab_hidden_floating_window_reveals_it`, `function_key_from_tab_hidden_dock_reveals_named_group`, `presentation_panel_command_reports_unavailable_without_mutating_layout`). - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). @@ -924,12 +941,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. Only the values `app`, `system` and `desktop` take precedence (case-sensitive, surrounding spaces ignored); any other value, such as `unknown`, is ignored and does not override System Title Bar. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. + - No two rows of Edit › Keyboard Shortcuts have the same English name (VectorCraft's own rule). A command that an item of the menu bar runs is listed under that item's menu path (Window › Swatch Libraries › Other Library…), a command whose id extends another command's id is listed under that command's label (Character Style Options › Reset Panel), and the rows that still share a name show their id after it, except the one row an item of the menu bar runs (File › New… and File › New… (file.new)). The search finds a row by the id it shows, and a message about a shortcut already in use names the other row with its group. Test `shortcut_editor::tests::no_two_entries_have_the_same_name`. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). - On Windows a maximized window stops one pixel short of each monitor edge where an auto-hidden taskbar sits. - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain. In Construction mode, a Selection-tool drag that drops an original shape wholly outside the bounds of the group's original shapes takes it out of the group in the move's undo step (Remove from Shaper Group, `shaper.releaseSource`): the shape becomes a path above the group and stays selected, and a group left with no shapes is removed. Engine tests drag a shape out of a two-shape group and back across the bounds, take the last shape out, undo, and save the result. + With a Shaper Group or one of its faces selected, a Shaper click within 3 screen pixels of an original shape's outline enters Construction mode with that shape selected, even where the outline runs through a merged face (`shaper.select`'s `tolerance`; [help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). Test: `tests::shaper::shaper_click_on_an_original_outline_enters_construction_mode`. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). The search for the segment of a path nearest a point, which Direct Selection runs to pick the cursor over a segment, skips each segment whose anchors and handles lie in a box farther from the point than a segment already measured, starting with a segment at the anchor nearest the point. The segment found is the same as before. Over a segment of an open path of 200,000 anchors, the cursor takes 51 ms instead of 59 ms in a release build (`cargo run --release -p vectorcraft-engine --example anchor_bench`). A Direct Selection marquee over part of a segment, with neither of its anchors inside, selects that segment as a click on it does (Shift toggles it), as [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describes (#4654). @@ -952,6 +971,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). Commands that act on `id` or else the selection, the highlighted layer row or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, the graphic, character and paragraph style commands, `image.info`, the `links.*` and `shaper.*` commands that take an `id`, opacity mask editing and viewing, `symbol.edit`, the blend spine commands and `path.curvature`) refuse an `id` that is not an object id (`"abc"`, `"5"`, `true`) and change nothing; `effect.apply` with the effect given as `id` still applies it to the selection. + `css.export` refuses a `scope` or `path` that is not a string (`5`, `true`, `["all"]`, `null`) before writing anything, instead of exporting the selection or returning the CSS as data; a left-out `scope` still exports the selection, and a left-out `path` still returns the CSS. `css.generate` and `css.export {scope: "all"}` refuse a malformed `ids` or `id`, which they do not use, as the selection's export does. Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. @@ -1014,6 +1034,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512). View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). + - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. @@ -1143,6 +1164,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - A butt-capped curve whose first or last handle sits on its end point also stops at that end: a straight 50 pt curve with a 2 pt stroke and its first handle on its start point measures 50 pt tall, not 51 pt (doc `reach::tests::a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end`). + - A bevel join, and a miter join whose miter limit bevels the corner, end the stroke at the corners of the two segments they join. VectorCraft's visual box of such a stroke, on closed and open paths, reaches a beveled corner only as far as those corners: a 40 × 100 pt triangle with a 20 pt stroke and a miter limit of 2 reaches about 1.96 pt above its tip instead of 10 pt. Round joins and the round caps of dashes, which can end at a corner, still reach half the weight round it, and the projecting caps of dashes reach the diagonal of their square. With Use Preview Bounds on, `object.setBounds` measures an object as drawn when a beveled or mitered corner, or a butt cap, is on a segment at a slant, so a width and height out of proportion give the size typed. A click past a bevel or a butt cap, by more than the click tolerance, does not select the stroke, except on a stroke with a width profile or with dashes that have round or projecting caps, and at the end of a curve that bends more tightly than half the weight. Public references: [Apply stroke color, width, and alignment](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/apply-stroke-color-width-and-alignment.html), [PDF 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.4.3.4 "Line Join Style" and 8.4.3.5 "Miter Limit" (doc `reach::tests::a_bevel_stops_at_the_corners_of_the_segments_it_joins` and `a_click_past_a_bevel_or_a_butt_end_misses`, effects `stroke::tests_reach::beveled_corners_are_measured_where_they_paint`, engine `tests::previewbounds::set_bounds_gives_a_closed_path_with_beveled_or_mitered_corners_the_size_out_of_proportion`). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size; the font size follows the vertical scale, so a W typed alone, out of proportion, changes the horizontal scale instead. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). @@ -1179,7 +1201,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. - The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. + The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes. The grid lists the selected character's alternate glyphs from `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. A double-click applies the feature that draws the variant, or a glyph choice if no supported feature draws it, as detailed below. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Native files keep a glyph choice with the face it was made in; another font or version draws the default glyph. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. + The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. @@ -1211,8 +1234,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. + The Paragraph panel menu's Bunri-Kinshi (`text.setFormat {bunriKinshi}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) keeps inseparable pairs on one line in each paragraph that has it: two em dashes, two ellipses, two two-dot leaders and the two halves of a vertical kana repeat mark (the pairs of W3C JLREQ's class cl-08, VectorCraft's set). Both composers and vertical type follow it, and justification adds no space inside a pair. It works with a Kinsoku Set only, is off by default and is saved only when on; editing the set of pairs is not available. The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. - The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. @@ -1246,8 +1270,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. +- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. With Transform Objects off, a member's mask takes the effect's stroke and pattern changes as the member does. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::a_transform_of_strokes_only_scales_a_members_mask_strokes`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. +- **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. +- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. @@ -1267,6 +1294,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, stacked layers only under their own areas:** with Method Overlapping, a layer reaches under the colors above it only within the connected areas that hold its own color, so a shape that sits apart from a color is no longer written again, at the same bounds, in that color, and the edge of that color's layer no longer shows through that shape's anti-aliased rim. *Measured* by `crates/trace/tests/quality` on each preset's own inputs: High Fidelity Photo 121 paths to 69.6 with ΔE 1.74 to 1.72, Low Fidelity Photo 37.1 paths to 26.1. Code-drawn disc, square and line tests in `crates/trace`. - **Image Trace, notches read only their own piece:** the notch rule of `subpixel::outline` reads only the shared piece, so both regions of an edge cut a corner pixel alike and leave no gap; a code-drawn noisy multi-colour coverage test in `crates/trace` (see [quality notes](image-trace-quality.md#notches-read-only-their-own-piece)). - **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)). +- **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged. - **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`. - **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`. - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). @@ -1276,6 +1304,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. +- **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`. - **Image Trace, Keep Colors:** `"keepColors": ["#rrggbb", …]` traces only the palette colors near a listed color (Abutting: each with the paths the trace of every color gives it; Overlapping: not reaching under the colors left out), in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; an image that isn't turned or skewed, isn't CMYK and can be read at its recorded size is cut from its own pixels when the crop edges fall on its pixel grid, copied as they are without resampling; any other image is drawn, #5534; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). @@ -1322,6 +1351,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. Unchecking Show Recent Colors in the Color panel's menu hides its Recent Colors row, and checking it shows the row again ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft the item is checked by default and kept between sessions, applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its row (`ui.colorPanel {recentColors}`). Tests: `show_recent_colors_hides_and_shows_the_row` in `crates/ui-egui/src/panels/color/tests.rs`. + A Recent Colors chip in the Color or Swatches panel drags into the Swatches panel's list to become a swatch of its color, in its color model, as one undo step ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft a chip dropped on a color group or one of its swatches joins that group, anywhere else in the list it joins the ungrouped swatches at the end, a color that is already a swatch is added again under another name, and dragging a chip does not paint the selected art (`swatch.new`). Tests: `a_recent_color_dragged_onto_the_list_becomes_a_swatch` and `a_recent_color_dragged_from_the_color_panel_becomes_a_swatch` in `crates/ui-egui/src/panels/swatches/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. A double-click right of an RGB field switches the Color panel's R, G and B between percent and 0 to 255 ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), Color panel). In VectorCraft the color does not change, Web Safe RGB and the hex field stay in 0 to 255, and the panel keeps the choice between sessions (`ui.colorPanel {rgbPercent}`); the color mixer of the Fill and Stroke chips and the gradient stop dialog follow the same setting. Tests: `rgb_percent_switches_the_rgb_fields_between_percent_and_0_to_255`, `rgb_percent_does_not_switch_while_the_rows_are_disabled` and `rgb_percent_comes_back_after_a_restart` in `crates/ui-egui/src/panels/color/tests.rs`. View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. @@ -1342,32 +1372,34 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C62 M19 Y0 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only. - **Generic CMYK (SWOP-like):** each ink's 40% and 50% tints gain as [CGATS TR 001](https://registry.color.org/cmyk-registry/cgats_tr_001) gives for SWOP printing, and separations stay within SWOP's [300% total ink limit](https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications). The paper, solid and overprint colors, the black-generation curve and the black point are VectorCraft's own values: RGB black separates to 90% black under neutral cyan, magenta and yellow that fill the ink limit (C75 M68 Y67 K90). - **Swatches made from a shown color in a CMYK document:** the Color panel menu's Create New Swatch, an Alt-click on the Swatches panel's New Swatch button, the New Swatch dialog for a gradient, a Fill or Stroke proxy or the Gradient panel's thumbnail dropped on the Swatches panel (or Alt-dropped on a swatch of its kind), and the Gradient panel's Add to Swatches and Save to Swatches keep each color's model, as the Color Guide's and Color Themes' saved colors do: an RGB object's color gives an RGB swatch, and a gradient's RGB stops stay RGB. - - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. + - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the XDG desktop portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) on Linux, and the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. On Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request another way each show a status message and end the wait. Windows, the BSDs and other browsers have no sampler yet; the button is gray there. - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. - The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403). - Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`. - Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient. + - Gradient spread, VectorCraft's own setting: Pad, Repeat or Reflect, with a Repeats count of 1 to 100, in the Gradient panel and on `paint.editGradient` and `paint.setFill`; Repeat and Reflect run a linear, radial, conic or diamond gradient's stops that many times along the vector and on past its ends, the Gradient tool marks where the runs meet, and SVG writes and reads `spreadMethod`, CSS writes repeating gradients, PDF writes a conic's sweep shading over one run and the other kinds with their stops repeated across the shape, as EPS, legacy `.ai` and the `.ai` editing data do. - The stop popover a double-click on a stop opens (the Gradient tool's annotator or the Gradient panel) has an eyedropper beside its Color and Swatches tabs: it closes the popover and samples the next colour clicked on the art into the stop, as the Gradient panel's eyedropper does (#4681). Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. + - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). - **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. - Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. Stylus Wheel, Tilt, Bearing and Rotation are not built. + Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. The menu also offers Tilt, Bearing and Rotation: the art goes from Width with the pen lying flat to the second width upright, and from Width at −180° to the second width at 180° of the pen's bearing or barrel rotation, as Scatter Brush Options' modes do, and half way on a stroke without that input; the page does not say which way each runs, so that mapping is VectorCraft's own. Evidence: `tests::art_brush_width_follows_tilt_bearing_and_rotation` (vectorcraft-brush). Stylus Wheel is not built. Scatter, Art and Pattern Brush Options have the Tips button for Colorization that the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes, beside Method. It opens Colorization Tips, which shows each method's name and what it does beside a sample of the brush, as OK would make it, painted with that method on a blue stroke; Hue Shift's sample uses the dialog's Key Color. `ui.dialog.set {field: "tips", value: true}` opens it. Screen readers name the button, the popover and each sample. Hue Shift set by `brush.new` or `brush.options` without a key color takes the color the art's fills and strokes use most, as Brush Options already offers it, following the Brushes page, where the key color defaults to the color that stands out most in the art (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>). The page does not state how prominence is measured. Counting the fills and strokes that use each solid color, with the first one found winning a tie and black for art with no solid color, is VectorCraft's own rule. A `brush.options` edit that gives only the method keeps the brush's key color, and one that gives only an art brush's start guide keeps its end guide. Evidence: `tests_brushsym::hue_shift_without_a_key_takes_the_most_used_art_color`, `tests_brushsym::art_brush_options_keep_the_scale_fields_an_edit_leaves_out`. Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests/brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy). In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. - Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). + Document Info has a Brushes category after Graphic Styles. It lists the names and types of the brushes applied to the artwork, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, or with Selection Only the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`, `brushes`). Listing each brush once, leaving out the Brushes panel's brushes no art uses (`panelBrushNames` lists the panel's brushes), hidden strokes, strokes at an opacity of 0, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape, and listing a brush the panel does not hold as missing, are VectorCraft's own rules. Tests: `cmd::docinfo::tests::info_lists_the_brushes` and `info_brushes_skip_undrawn_strokes_and_name_missing_brushes` (vectorcraft-engine). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). - Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). + Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). + Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0 and a grayscale image colorized with one, leaving out process colors that look the same, Registration, gradients whose stops are all at an opacity of 0, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Tests: `cmd::docinfo::tests::info_lists_the_spot_color_objects` and `spot_color_objects_take_colorized_images_and_skip_clear_gradients` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. @@ -1515,6 +1547,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). `.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + Explicitly requested editing data keeps the full single artboard when its PDF page is capped at 14,400 pt (#6865; `crates/eps/src/import/tests_native.rs`). Scores and estimates unchanged. A `.ai`, or a PDF saved with Illustrator's editing data, whose PDF part can't be read (a stream the import refuses) opens from its editing data, with the PDF part's error as its first warning; File › Place places its first artboard the same way, and Paste of it as PDF data with `editingData: true` takes every object (part of #4366; `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.rs`). @@ -1569,6 +1602,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. + - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default). - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. @@ -1592,7 +1626,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur anchor or with Alt held over a path (convert) and over another open path's end (join). The Eyedropper has an original pipette glyph with its tip at the hotspot; Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests/prefs.rs` cover #1269; - actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed. + actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). While the host owns the cursor it sets it again on every pass instead of comparing it with `NSCursor.currentCursor`, which is the app's last set cursor rather than the one on screen, so the tool's cursor no longer gives way to the system arrow when the pointer moves (#6362). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed. In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. @@ -1703,6 +1737,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. +- **Blend widgets on the canvas:** with one blend selected that has a spine, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow that spine; a blend made without a spine shows neither. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. + - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. @@ -1726,6 +1762,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. +- **A dialog's OK runs what it was opened for:** `ui.dialog.set`, and MCP's `answer_dialog` through it, can set a `__` field only when it is a control of that kind of dialog (a section or tab shown, a link between two fields, a name or search being typed). Every other `__` field, such as the `__command` and `__params` of a confirmation or the generic parameter dialog, a plug-in command dialog's `__command` and `__plugin`, or the paste a swatch conflict waits to finish, is refused with "the `<kind>` dialog cannot set protected field `<field>`"; the request sets no field, and with `action: "confirm"` OK does not run. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/control/tests.rs`. Scores and hours unchanged. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. @@ -1733,6 +1770,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. +- **The command sweep sends file and folder params into its temp folder by the command catalog:** the sweeps in `crates/engine/tests/command_sweep.rs` take each command's file and folder keys from the catalog (`cmd::location_keys`, the same key list the plug-in call rules use, less the keys that name part of the document in a command), read every key a params description names (`{path | name …}`, `{folder? (…)}` included) and its schema's params, and send every string under such a key, in arrays and in objects inside them (`paths: [{ids, path}]`), as a location in the temp folder, so a command added later is swept and checked without editing the sweep. `diffPath` counts as a file key, and Slice Options' `url` and Export for Screens' `folders` do not. Regressions: `sweep_cases_name_files_only_inside_the_temp_folder` and `sweep_cases_send_the_file_keys_of_the_command_catalog` in `command_sweep`, `location_keys_come_from_the_command_catalog` in `cmd::plugin_call`. Scores and hours unchanged. - **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. @@ -1980,6 +2018,10 @@ A VectorCraft addition, not a feature of the reference app (it has no command fo A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Stencil Bridges… cuts strips of a set width (0.01 to 1000 pt, 2 by default) out of the selected art so that a stencil cut from it keeps its islands, such as the inside of an O or a hole in a shape, attached to the sheet ([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). Every island is joined to the sheet, directly or through another island, by the shortest crossings that fit, then gets more bridges spread around it up to the count asked for (1 to 16); bridges run vertically, horizontally or along the shortest crossing among 12 directions 15° apart. The art is measured by the area its outlines enclose (hidden objects left out), type by its glyph outlines without converting the type, and becomes one compound path with the gaps cut; the selected objects that make up the art go to a new hidden layer, Stencil Original, or are replaced, and selected objects with no outlines stay where they are. Art with no islands is left as it is with a message and no undo step, islands the cut result still encloses are counted and reported, and the run is one undo step. Tests: `addons::stencil::tests` in `vectorcraft-pathops` (an O, two holes, nested islands, curved outlines, width, direction, the shortest crossing at an angle, more bridges per island and their spacing, narrow islands, slivers, outlines that touch at a corner, the curve tolerance, open outlines, limits and the work budget), `cmd::addons::cutting::stencil::tests` in `vectorcraft-engine` (the hidden layer and undo, replacing, width with units, type, hidden group members, objects with no outlines, the result's paint, kind and place in the stacking order, no islands, narrow islands, bad params refused), `stencil_bridges_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Stencil Bridges. +## Cutting Tools add-on: Weed Box and Lines (2026-11-12) + +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Cutting Tools › Weed Box and Lines… (`addon.cutting.weed`) adds a weed box and weeding lines around the selected art for vinyl cutting ([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The rest is VectorCraft's own: the box is a rectangle at a margin around the art, and the lines run across it horizontally, vertically or both ways, by count or by spacing, and stop at the art's outlines or at a gap from them. The box and the lines are straight paths stroked in a chosen swatch ([Registration] by default), in one group named Weeding on top of the current layer, added as one undo step. The art is measured by the outlines a cutter cuts: compound paths by their fill rule, type by its glyph outlines, clip groups by what shows, images by their frames, placed files by the art they show, symbol instances by their art, and blends and other live objects by the art they draw. The dialog has Preview. Tests: `cmd::addons::cutting::weed::tests` in `vectorcraft-engine`, `weed_box_and_lines_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Weed Box and Lines. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. @@ -2010,7 +2052,7 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance, results with no pixel left and grayscale images colorized with a process color are refused (the result has an alpha channel, and a process color can't colorize a grayscale image with one: [Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"). The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). ## Color Effects add-on: Find Nearest Swatch (2026-11-01) @@ -2080,12 +2122,17 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | +| 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | +| 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | +| 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. | | 2026-11-12 | minor | UI chrome: a numeric-field focus click released more than 1 pt from its press keeps the caret; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); scores and hours unchanged. | | 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | +| 2026-11-11 | minor | Interim macOS canvas cursor delivery (#2292): the tool's cursor stays after the pointer moves (#6362); tool cursors evidence updated; scores and hours unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | diff --git a/docs/three-d.md b/docs/three-d.md index a79e6a4900..820bc1e805 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -51,6 +51,19 @@ pages describe it. unavailable while a setting of a single light differs between the selected effects. - Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. +- **Quick Actions › Expand as Wireframes** sits under the Object tab (under the first tab for Revolve and Rotate, + which have no Object tab). It replaces each selected object that has a 3D effect, the 3D (Classic) effects included, with a group of paths, the + edges of the faces the effect draws for the object as it is turned and projected now (`effect.expandAsWireframes`), + as the public [Render 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/render-3d-vector-artwork.html) + page describes for converting the wireframe into editable paths. Each edge appears once: edges that meet end to + end form one path, closed where it comes back to its start, and curved edges stay curves. A face partly covered + by another face gives its whole outline. Faces turned away from the viewer are included where the effect draws + them (Draw Hidden Faces and the Wireframe surface of Extrude & Bevel (Classic)). Geometry effects after a 3D + effect, on the object or on a fill or stroke, reshape the edges as they reshape the drawn art. Shadows are not + part of the wireframe. The paths have a 0.5 pt black stroke with round joins and no fill, which is VectorCraft's + own choice. The group keeps the object's place, id, name, opacity, blending mode, opacity mask, attributes + and text wrap and is selected; objects without a visible 3D effect do not change. It is one undo step, and an error changes nothing + when no selected object has a visible 3D effect. - The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the options dialog only. diff --git a/docs/type-parity.md b/docs/type-parity.md index 30547d5a17..39f2713ac5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Entire Font respects the selection's variable axes and font version; the default cell can reset an unlisted choice without GSUB alternates. Contextual choices use the supplying face, including fallback. Feature-only boundaries preserve ligature clusters and Arabic mark attachment. Encoded characters insert unchanged except Arabic presentation forms with a joining base letter; normalization reports success only after insertion succeeds. Latest follow-up regressions await runtime verification. Scores and estimates unchanged. + Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the code and tests (`crates/text`, `crates/engine/src/cmd/type*.rs`, `textstyles.rs`, `crates/tools/src/text.rs`) @@ -30,8 +32,9 @@ against Illustrator's public documentation; **estimated** unless noted. | Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet | +| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972); wherever the font menu opens, its filters keep the same four rows, and it opens at least wide enough for them and for a family name up to 220 points wide with its sample (up to 60% of the window), each sample filling its row's height (a VectorCraft layout). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | @@ -81,6 +84,10 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Glyphs review (#6084): validate contextual glyph choices against their supplying face, including fallback and overflow; keep feature-only boundaries inside ligatures and Arabic mark clusters while retaining drawing-attribute splits; preserve insertion failures instead of reporting normalization success. Deterministic engine, shaping and panel regression sources added; ligature range assertions use the fixture's unencoded glyph identity. Runtime proof pending. Scores and estimates unchanged. | +| 2026-11-12 | minor | Glyphs review (#6084): Entire Font resolves the selected font's version and variable axes; the default cell remains available to reset an unlisted choice with no listed alternates. Character insertion normalizes only Arabic presentation forms, preserving ordinary characters whose glyphs share Arabic substitution provenance. Add deterministic panel apply/reset/Undo regressions without OS-font dependencies. Scores and estimates unchanged. | +| 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. | +| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | | 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. | | 2026-11-11 | minor | Type in envelopes: its strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them; with Distort Appearance they bent with the art as their filled outlines. The same holds in an envelope inside an envelope. Geometry effects on type's fills and strokes, and an opacity mask on type, still bend; Expand gives the bent outlines with the strokes on them. The visual bounds of envelopes, blends and repeats count type's character strokes as type's own visual bounds do. This changes how VectorCraft's own envelopes of stroked type draw (#6644). Score unchanged (estimated) | | 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bb2857d119..0f9beab5be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **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: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; 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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -15,7 +15,7 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow | 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged | Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. -Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. +Icon-strip flyouts resize from the canvas-side edge, the top, the bottom and the corners on the canvas side, keeping the icon-column edge anchored (#5713, storytold/craft-libs#1094); a top-edge resize keeps the bottom and saves the moved top per group in workspaces. Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged. **PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged. @@ -62,6 +62,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-06 | minor | Shape tools: a press on the selection's centre point (Show Center) moves the selection, also where no art is painted, and a press in the rotate zone just outside a bounding-box corner turns it about the centre, both as the Selection tool's do (#5477); the Selection tool takes its centre point too, so an unfilled shape moves instead of starting a marquee; scores and estimates unchanged | | 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar — a field's scrub label was a second, invisible tab stop, and the W/H link between W and H is now made after the fields, so it stays a tab stop after them; scores and estimates unchanged | | 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: and the Properties panel's Stroke, links to their panels, scrub the field after them as other labels do; a click still opens the panel (#4088); scores and estimates unchanged | +| 2026-11-12 | minor | Icon-strip flyouts also resize from the top edge and the canvas-side top corner; the moved top is kept per group in workspaces (storytold/craft-libs#1095). Scores and estimates unchanged. | | 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. | | 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged | | 2026-11-05 | minor | Direct Selection: Cmd/Ctrl held lends the Selection tool, whose bounding box shows while the key is down over objects selected whole; its handles scale and rotate, and a Cmd drag moves the whole object with its direct-selected anchors kept (#3901); scores and estimates unchanged | @@ -163,6 +164,7 @@ tools, never checked side by side) isn't captured row by row. | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | | Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | +| Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `<Path>` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` | | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 | @@ -332,7 +334,7 @@ tools, never checked side by side) isn't captured row by row. | Tool double-click opens its options | done | | | Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | | Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | -| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | +| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. While it owns the cursor, the host sets it again on every pass, so the tool's cursor stays when the pointer moves (#6362). Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | ## Keyboard shortcuts @@ -374,7 +376,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`; `panels::learn::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`, `learn.locateTool`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | @@ -410,6 +412,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; rotated copies and highlighting matches before the start remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | +| Stroke panel: custom arrowheads after the built-in ones in both arrowhead menus, from the arrowheads file that the panel menu's Edit Arrowheads… opens; saving the file reloads the menus, and a stroke keeps a copy of its custom head's art | done | [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) (Customize arrowheads); the file's layout (one symbol per head, pointing right, tip at the registration point, height drawn as wide as the built-in heads) is VectorCraft's own; `stroke.arrowheads.edit`, `stroke.arrowheads.list`, `stroke.arrowheads.reload`, `stroke.set` `{custom: name}`; engine `tests::arrowheads`, `stroke::tests_arrows::custom_heads_draw_their_own_art_at_the_tip` | | Control bar: Paragraph: link and Align left/center/right while type is selected (#5372) | done | After the Character controls, as the public documentation has the Control panel format paragraphs ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html), [Align text](https://helpx.adobe.com/illustrator/using/align-text.html)); the link opens the Paragraph panel in a popover, as Character: does with the Character panel. The bar trims from its right end: X/Y/W/H, Distribute, Align, area type vertical alignment, then these buttons, then the link. The three-button set is our choice for room; all seven alignments are in the popover. `panels/paragraph/tests.rs` headless tests (`the_control_bar_shows_the_paragraph_alignment_of_the_selected_type`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right`) | | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | @@ -526,6 +529,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | +| 2026-11-11 | minor | macOS canvas cursor delivery (#2292): while the host owns the cursor it sets it again on every pass, so the tool's cursor stays after the pointer moves (#6362); `mac_cursor` ownership test updated; scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). |